feat(ux): import compact + preview format Trimiteri + navigatie + scoatere auto_send (5.11)

8 stories TDD (echipa Sonnet, lead orchestreaza). US-001 scoate hold-ul auto_send din mapare
(has_no_auto_send->False, simbol pastrat; cod rezolvat->queued). US-002 scoate bifa auto_send
din UI. US-003 preview pas 3 in format .tabel-trimiteri (STARI_PREVIEW + nota_umana_preview,
fara repr Python; view-model prez). US-004 filtre layout/stil ca referinta + buton Custom.
US-005 navigatie Trimiteri/Mapari sub contoare pe toate paginile. US-006 import <details> nativ
colapsabil. US-007 post-commit reveal (OOB _coada/_status + HX-Trigger). US-008 auto-refresh
dupa actiuni (nudge eliminat).

VERIFY context curat PASS (8/8). /code-review high: 3 buguri reparate (tab nav la self-refresh,
pill Custom valori stale, nota_umana_preview precedenta needs_mapping). 934 passed, 1 skipped.
Backend trimitere + schema NEATINSE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-26 15:16:28 +00:00
parent 412102b9b1
commit 283299ff20
34 changed files with 3079 additions and 389 deletions

18
TODOS.md Normal file
View File

@@ -0,0 +1,18 @@
# TODOS
Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand devin prioritare.
## Din /autoplan PRD 5.11 (2026-06-26)
- [ ] **E2E smoke de first-run ca poarta de release** — codifica scriptul de dogfooding
(import -> mapcoloane -> preview -> commit -> lista apare + contoare) ca test E2E care
trebuie sa treaca inainte de orice release. Motiv: cele 8 bug-uri din 5.11 sunt toate
first-run friction nedogfooded end-to-end; fara o poarta, reapar ca 8 tichete noi.
(CEO F2, severitate high.)
- [ ] **Control compensator optional pe auto-trimitere unattended** — utilizatorul a ales
(2026-06-26) scoaterea completa a hold-ului auto_send. Risc rezidual acceptat: o regula
text gresita poate auto-trimite FINALIZATA (terminal, fara undo) pe randuri pe canalul API /
remapare inline (fara gate de preview). Daca apar integratori reali, evalueaza un throttle
„primele N auto-trimiteri pe o regula text noua cer confirmare" sau un kill-switch per cont.
(CEO F5/F6, severitate critical ca risc, dar pre-launch exposure ~zero acum.)

View File

@@ -221,7 +221,7 @@ def _resolve_row_for_preview(
errors = validate_prezentare(mapped) errors = validate_prezentare(mapped)
if all_flags: if all_flags:
# needs_review: chiar daca validarea trece, flagurile blocheaza auto-send # needs_review: validarea a trecut, dar flagurile (date ambigue, formule) cer confirmare manuala
return { return {
"resolved_status": "needs_review", "resolved_status": "needs_review",
"resolved": mapped, "resolved": mapped,
@@ -229,14 +229,7 @@ def _resolve_row_for_preview(
"flags": all_flags, "flags": all_flags,
} }
# auto_send gate # US-001 (PRD 5.11): ramura auto_send eliminata din preview.
if has_no_auto_send(resolved, mapping_meta):
return {
"resolved_status": "needs_mapping",
"resolved": mapped,
"errors": [{"auto_send": "cod mapat cu auto_send=0; review manual inainte de trimitere"}],
"flags": all_flags,
}
if errors: if errors:
return { return {

View File

@@ -87,8 +87,10 @@ def _erori_nemapate(unmapped: list[dict]) -> list[dict]:
def _motiv_clasificare(cl: dict) -> str | None: def _motiv_clasificare(cl: dict) -> str | None:
"""Rezumat uman pe o linie pentru un rezultat de clasificare. """Rezumat uman pe o linie pentru un rezultat de clasificare.
None cand status='queued'. Acopera toate ramurile de blocaj: erori de continut None cand status='queued'. Acopera ramurile de blocaj: erori de continut
(needs_data), coduri nemapate (needs_mapping) si auto_send oprit (needs_mapping). (needs_data) si coduri fara mapare RAR (needs_mapping).
Dupa US-001: needs_mapping apare EXCLUSIV cand unmapped e non-gol
(ramura auto_send_oprit era inaccesibila si a fost eliminata).
""" """
if cl["status"] == "queued": if cl["status"] == "queued":
return None return None
@@ -99,8 +101,6 @@ def _motiv_clasificare(cl: dict) -> str | None:
if cl["unmapped"]: if cl["unmapped"]:
coduri = ", ".join((u.get("cod_op_service") or "") for u in cl["unmapped"]) coduri = ", ".join((u.get("cod_op_service") or "") for u in cl["unmapped"])
return f"Coduri fara mapare RAR: {coduri}" return f"Coduri fara mapare RAR: {coduri}"
if cl["status"] == "needs_mapping":
return "Cod cu trimitere automata oprita; confirmare manuala inainte de trimitere."
return None return None
@@ -156,7 +156,7 @@ def create_prezentari(
conn = get_connection() conn = get_connection()
results: list[SubmissionResult] = [] results: list[SubmissionResult] = []
try: try:
# load_mapping_meta include auto_send per op (gate pentru coduri noi). # load_mapping_meta incarca maparea op->cod RAR; dupa US-001, auto_send nu mai tine randuri.
mapping_meta = load_mapping_meta(conn, acct) mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat. # Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat.
@@ -230,7 +230,7 @@ def create_prezentari(
continue continue
# Helper pur partajat cu dry-run: reproduce EXACT clasificarea # Helper pur partajat cu dry-run: reproduce EXACT clasificarea
# (canonicalize + mapare op->cod + validare + auto_send gate). # (canonicalize + mapare op->cod + validare; auto_send gate eliminat dupa US-001).
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules) cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
if cl["blocked_error"]: if cl["blocked_error"]:
# on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat). # on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).

View File

@@ -249,10 +249,8 @@ def resolve_prestatii(
# Adnotare aditiva: marcheaza ca rezolvat-prin-regula cu pattern-ul # Adnotare aditiva: marcheaza ca rezolvat-prin-regula cu pattern-ul
# sursa. Payload-harmless (RAR citeste doar cod_prestatie). # sursa. Payload-harmless (RAR citeste doar cod_prestatie).
it["cod_sursa"] = f"{COD_SURSA_TEXT_RULE_PREFIX}{pattern_regula or ''}" it["cod_sursa"] = f"{COD_SURSA_TEXT_RULE_PREFIX}{pattern_regula or ''}"
# Siguranta: regula cu auto_send=0 rezolva codul dar TINE randul # US-001 (PRD 5.11): regula_fara_autosend nu se mai seteaza;
# pentru verificare umana (has_no_auto_send -> True). # auto_send nu mai tine randul (has_no_auto_send neutralizat).
if not auto_send_regula:
it["regula_fara_autosend"] = True
else: else:
it["cod_prestatie"] = None it["cod_prestatie"] = None
unmapped.append({"cod_op_service": op, "denumire": it.get("denumire")}) unmapped.append({"cod_op_service": op, "denumire": it.get("denumire")})
@@ -410,14 +408,9 @@ def classify_prezentare(
if errors: if errors:
status = "needs_data" status = "needs_data"
rar_error = json.dumps(errors, ensure_ascii=False) rar_error = json.dumps(errors, ensure_ascii=False)
elif has_no_auto_send(resolved, mapping_meta):
status = "needs_mapping"
mesaj = "cod mapat cu auto_send=0; review manual inainte de trimitere"
rar_error = json.dumps(
{"auto_send": mesaj, **err_mod.eroare("AUTO_SEND_OPRIT", cauza=mesaj)},
ensure_ascii=False,
)
else: else:
# US-001 (PRD 5.11): ramura AUTO_SEND_OPRIT eliminata.
# Un cod rezolvat (mapare exacta sau regula text) -> queued direct.
status = "queued" status = "queued"
rar_error = None rar_error = None
@@ -432,20 +425,14 @@ def classify_prezentare(
def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> bool: def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> bool:
"""Verifica daca vreun item rezolvat are auto_send=0 (mapare exacta SAU regula text). """Neutralizat dupa US-001 (PRD 5.11): auto_send nu mai tine randuri in needs_mapping.
Un cod nou-mapat (operations_mapping) cu auto_send=0 nu trebuie trimis automat. Simbolul este PASTRAT (importat in routes.py si import_router.py); stergerea
La fel pentru un item rezolvat printr-o REGULA TEXT cu auto_send=0 — marcat de ar produce ImportError la boot. Functia intoarce mereu False — codul rezolvat
`resolve_prestatii` cu `regula_fara_autosend`. In ambele cazuri randul ramane intra direct in queued, indiferent de valoarea auto_send din mapping_meta.
needs_mapping (review manual) pana cand operatorul activeaza „In coada".
Items cu cod_prestatie direct (nu via cod_op_service/regula) nu sunt afectate. Coloanele DB raman cu default=1 (migrare non-distructiva).
""" """
for item in resolved:
if item.get("regula_fara_autosend"):
return True
op = (item.get("cod_op_service") or "").strip()
if op and op in mapping_meta and not mapping_meta[op]["auto_send"]:
return True
return False return False
@@ -660,18 +647,8 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
stats["still_blocked"] += 1 stats["still_blocked"] += 1
continue continue
# Verifica auto_send inainte de re-queuing. # US-001 (PRD 5.11): ramura auto_send eliminata din reresolve.
if has_no_auto_send(resolved, mapping_meta): # Un cod rezolvat -> queued direct (review_manual ramane 0).
conn.execute(
"UPDATE submissions SET payload_json=?, rar_error=?, updated_at=datetime('now') WHERE id=?",
(
payload_json,
json.dumps({"auto_send": "cod mapat cu auto_send=0; review manual inainte de trimitere"}, ensure_ascii=False),
r["id"],
),
)
stats["review_manual"] += 1
continue
errors = validate_prezentare(content) errors = validate_prezentare(content)
if errors: if errors:

View File

@@ -327,6 +327,80 @@ def parse_erori(rar_error: object) -> list[dict]:
return [{"problema": str(data)[:200], "cauza": "", "fix": "", "field": None}] return [{"problema": str(data)[:200], "cauza": "", "fix": "", "field": None}]
# ---------------------------------------------------------------------------
# Etichete stari preview import (vocabular DIFERIT de starile de submission)
#
# Starile de preview (ok/needs_review/already_sent/duplicate_in_file) NU
# exista in STARI_SUBMISSION — reutilizarea directa a eticheta_stare/eticheta_scurta
# ridica KeyError. Acest map este sursa de adevar pentru stratul de adaptare din
# _web_compute_preview (routes.py) si pentru template (_preview_rand.html).
# ---------------------------------------------------------------------------
STARI_PREVIEW: dict[str, tuple[str, str]] = {
"ok": ("Gata de trimis", "s-ok"),
"needs_review": ("Verifica valori", "s-needs_review"),
"needs_mapping": ("Cod RAR lipsa", "s-needs_mapping"),
"needs_data": ("Date incomplete", "s-needs_data"),
"already_sent": ("Deja trimis", "s-already_sent"),
"duplicate_in_file": ("Duplicat in fisier", "s-duplicate_in_file"),
}
def nota_umana_preview(status: str, errors: list, flags: list) -> str:
"""Formateaza mesajul uman pentru coloana Note din tabelul de preview import.
Primeste ``errors`` ca lista Python (nu JSON string) — NU pasa la motiv_uman
sau parse_erori care asteapta un JSON string si ar produce repr Python brut
prin fallback ``raw[:160]`` (bug documentat in PRD 5.11 US-003).
Logica de prioritate:
- already_sent / duplicate_in_file -> "" (template le afiseaza separat)
- needs_mapping -> unmapped INAINTE de flags (codul lipsa e motivul real)
- flags non-goale -> primul flag (needs_review: data ambigua etc.)
- errors cu cheie "unmapped" -> "Cod RAR lipsa pentru: COD1, COD2"
- errors cu field+message (needs_data) -> primul mesaj de validare
- altceva -> ""
Fara exceptii. Trunchiat la 200 caractere.
"""
if status in ("already_sent", "duplicate_in_file"):
return ""
# needs_mapping: codul RAR lipseste — prioritizeaza 'unmapped' inaintea flags,
# altfel un rand cu si un flag (ex. VIN numeric) ar afisa textul flag-ului
# si ascunde motivul real (cod lipsa).
if status == "needs_mapping":
for e in errors:
if not isinstance(e, dict):
continue
if "unmapped" in e:
ops = e.get("unmapped") or []
coduri = ", ".join(
o.get("cod_op_service", "") for o in ops if isinstance(o, dict)
)
return ("Cod RAR lipsa pentru: " + coduri if coduri else "Cod RAR lipsa")
if flags:
return str(flags[0])[:200]
for e in errors:
if not isinstance(e, dict):
continue
if "unmapped" in e:
ops = e.get("unmapped") or []
coduri = ", ".join(
o.get("cod_op_service", "") for o in ops if isinstance(o, dict)
)
return (f"Cod RAR lipsa pentru: {coduri}" if coduri else "Cod RAR lipsa")
msg = (
e.get("message")
or e.get("msg")
or e.get("problema")
or e.get("cauza")
or ""
)
if msg:
return str(msg)[:200]
return ""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Constante auxiliare (microcopy fix, fara logica) # Constante auxiliare (microcopy fix, fara logica)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -29,12 +29,14 @@ from ..payload_view import prezentare_din_payload
from ..web.csrf import get_csrf_token, verify_csrf from ..web.csrf import get_csrf_token, verify_csrf
from .labels import ( from .labels import (
ETICHETA_ULTIMA_AUTENTIFICARE_RAR, ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
STARI_PREVIEW,
eticheta_rar, eticheta_rar,
eticheta_scurta, eticheta_scurta,
eticheta_stare, eticheta_stare,
eticheta_worker, eticheta_worker,
format_data_rar, format_data_rar,
motiv_uman, motiv_uman,
nota_umana_preview,
parse_erori, parse_erori,
) )
from ..web.session import require_login from ..web.session import require_login
@@ -593,6 +595,40 @@ def _pills_categorii(counts: dict[str, int]) -> list[dict]:
] ]
def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = False, tab_activ: str = "acasa") -> dict:
"""Construieste dictionarul de context pentru _status.html.
Accepta o conexiune deja deschisa (nu deschide alta). Folosit de fragment_status
si de web_confirma_import (OOB swap dupa commit).
"""
counts = _status_counts(conn, account_id)
hb = read_heartbeat(conn)
worker_alive = _worker_alive(hb)
rar_state = _rar_state(hb, worker_alive)
worker_lbl = eticheta_worker(worker_alive)
rar_ok = rar_state == "ok"
rar_lbl = eticheta_rar("ok" if rar_ok else rar_state)
blocate_total = sum(counts.get(s, 0) for s in _BLOCKED)
return {
"request": request,
"worker_lbl": worker_lbl,
"rar_lbl": rar_lbl,
"worker_ok": worker_alive,
"rar_ok": rar_ok,
"eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
"last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None),
"counts_queued": counts.get("queued", 0),
"counts_sent": counts.get("sent", 0),
"blocate_total": blocate_total,
"blocate_defalcat": _blocate_defalcat(counts),
"pills_categorii": _pills_categorii(counts),
"account_active": _account_active(conn, account_id),
"tab_activ": tab_activ,
"mapari_badge": counts.get("needs_mapping", 0),
"oob": oob,
}
@router.get("/_fragments/status", response_class=HTMLResponse) @router.get("/_fragments/status", response_class=HTMLResponse)
def fragment_status(request: Request) -> HTMLResponse: def fragment_status(request: Request) -> HTMLResponse:
"""Bara de status persistenta cu etichete umane. """Bara de status persistenta cu etichete umane.
@@ -604,34 +640,9 @@ def fragment_status(request: Request) -> HTMLResponse:
account_id = require_login(request) account_id = require_login(request)
conn = get_connection() conn = get_connection()
try: try:
counts = _status_counts(conn, account_id) tab_activ = request.query_params.get("tab", "acasa")
hb = read_heartbeat(conn) ctx = _build_status_ctx(request, conn, account_id, tab_activ=tab_activ)
worker_alive = _worker_alive(hb) return templates.TemplateResponse("_status.html", ctx)
rar_state = _rar_state(hb, worker_alive)
# Etichete umane pre-calculate (nu logica in template)
worker_lbl = eticheta_worker(worker_alive)
# eticheta_rar accepta "ok" sau orice alt string -> indisponibil/necunoscut
rar_ok = rar_state == "ok"
rar_lbl = eticheta_rar("ok" if rar_ok else rar_state)
blocate_total = sum(counts.get(s, 0) for s in _BLOCKED)
return templates.TemplateResponse("_status.html", {
"request": request,
"worker_lbl": worker_lbl,
"rar_lbl": rar_lbl,
# Stari binare pentru bife accesibile: glifa + culoare
"worker_ok": worker_alive,
"rar_ok": rar_ok,
"eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
"last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None),
"counts_queued": counts.get("queued", 0),
"counts_sent": counts.get("sent", 0),
"blocate_total": blocate_total,
"blocate_defalcat": _blocate_defalcat(counts),
"pills_categorii": _pills_categorii(counts),
"account_active": _account_active(conn, account_id),
})
finally: finally:
conn.close() conn.close()
@@ -1163,21 +1174,7 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
message="Lipseste inca un cod RAR — alege-l mai jos sau in tab-ul Mapari."), message="Lipseste inca un cod RAR — alege-l mai jos sau in tab-ul Mapari."),
) )
if has_no_auto_send(resolved, mapping_meta): # US-001 (PRD 5.11): ramura auto_send eliminata din corectie.
conn.execute(
"UPDATE submissions SET status='needs_mapping', payload_json=?, rar_error=?, "
"updated_at=datetime('now') WHERE id=?",
(payload_json,
json.dumps({"auto_send": "cod mapat cu auto_send=0; review manual inainte de trimitere"},
ensure_ascii=False),
row["id"]),
)
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(request, row2, error=True,
message="Cod cu auto-send oprit — confirma manual din tab-ul Mapari."),
)
errors = validate_prezentare(content) errors = validate_prezentare(content)
if errors: if errors:
@@ -2025,6 +2022,29 @@ def _web_compute_preview(
except Exception: except Exception:
conn.execute("ROLLBACK") conn.execute("ROLLBACK")
# Enrichment UI: adauga campuri pre-computate necesare template-ului.
# Toate consumatorii (preview complet, rand single via _preview_one_row)
# obtin automat campurile adaugate aici.
for row in preview_rows:
# view-model prez (vehicul/operatie/cod RAR) — prezentare_din_payload
# accepta dict direct (nu e nevoie de serializare/deserializare JSON).
row["prez"] = prezentare_din_payload(row["resolved"])
# Eticheta umana + clasa CSS pentru pill — din STARI_PREVIEW, nu STARI_SUBMISSION
# (eticheta_stare ridica KeyError pe ok/already_sent/duplicate_in_file).
_etq, _css = STARI_PREVIEW.get(
row["resolved_status"],
(row["resolved_status"], f"s-{row['resolved_status']}"),
)
row["stare_eticheta"] = _etq
row["stare_css"] = _css
# Nota umana formatata — errors e lista Python, NU JSON string;
# nota_umana_preview o trateaza corect (fara repr Python brut in Note).
row["nota_umana"] = nota_umana_preview(
row["resolved_status"],
row.get("errors") or [],
row.get("flags") or [],
)
nomenclator = load_nomenclator(conn) nomenclator = load_nomenclator(conn)
return { return {
"rows": preview_rows, "rows": preview_rows,
@@ -2750,18 +2770,33 @@ async def web_confirma_import(
(n_enqueued, import_id), (n_enqueued, import_id),
) )
# Succes → bara de upload slim cu mesaj de confirmare. are_trimiteri=True: # Succes → bara de upload slim cu mesaj de confirmare + OOB swap al
# contul tocmai a pus randuri in coada -> bara ramane slim si dezvaluie # #trimiteri-section (injecteaza _coada.html cu lista proaspata) +
# sectiunea "Trimiterile tale" de pe Acasa. # header HX-Trigger: trimiteriChanged (declanseza reincarcarea automata).
toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else "" toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else ""
return templates.TemplateResponse("_upload.html", _ctx( succes_msg = (
request, f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. "
are_trimiteri=True, f"Procesarea incepe in cateva secunde — vezi mai jos, in Trimiterile tale."
message=( )
f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. "
f"Procesarea incepe in cateva secunde — vezi mai jos, in Trimiterile tale." # Calculeaza contextele (necesita conn deschis) inainte de finally.
), acasa_ctx = _get_acasa_context(request, conn, account_id)
)) acasa_ctx["status_filtru"] = ""
acasa_ctx["oob"] = True # adauga hx-swap-oob="outerHTML" la <section>
status_ctx = _build_status_ctx(request, conn, account_id, oob=True)
# Randeaza imediat (conn inca deschis — query-urile s-au facut mai sus).
upload_html = templates.get_template("_upload.html").render(
_ctx(request, are_trimiteri=True, message=succes_msg)
)
coada_html = templates.get_template("_coada.html").render(acasa_ctx)
status_html = templates.get_template("_status.html").render(status_ctx)
return HTMLResponse(
content=upload_html + "\n" + coada_html + "\n" + status_html,
headers={"HX-Trigger": "trimiteriChanged"},
)
finally: finally:
conn.close() conn.close()

View File

@@ -1,7 +1,16 @@
<div id="acasa-section"> <div id="acasa-section">
{# === Centru de greutate: bara de upload (importul e operatia principala) === #} {# === Container colapsabil: stepper + upload intr-un singur element <details> (US-006).
{% include '_upload.html' %} Serverul seteaza atributul `open` din are_trimiteri:
are_trimiteri=False (first-run) → open (importul e vizibil imediat, fara JS)
are_trimiteri=True (returning) → colapsat (nu ocupa ecranul, dar e accesibil la click)
Degradare fara JS: corecta pe ambele ramuri.
In timpul fluxului (mapcoloane/preview), HTMX face swap pe #import-section (descendentul
intern) → <details> ramane neatins → containerul ramane deschis intre pasi. === #}
<details id="import-details"{% if not are_trimiteri %} open{% endif %}>
<summary>Importa un fisier</summary>
{% include '_upload.html' %}
</details>
{# === Subordonat: primii pasi pe un singur rand compact === #} {# === Subordonat: primii pasi pe un singur rand compact === #}
{% set toti_esentiali = are_creds and are_trimiteri %} {% set toti_esentiali = are_creds and are_trimiteri %}
@@ -44,10 +53,14 @@
</div> </div>
{% endif %} {% endif %}
{# Sectiunea Trimiteri, permanenta sub upload. Suprimata la first-run (zero {# Sectiunea Trimiteri, permanenta sub upload.
trimiteri): bara de upload acopera deja CTA-ul, iar empty-state-ul ar fi redundant. #} La first-run (zero trimiteri), randam un placeholder <section> gol/ascuns — necesar
ca OOB swap-ul de la confirma sa gaseasca tinta valida in DOM si sa injecteze
_coada.html fara reload complet. Fara placeholder, HTMX ignora silentios OOB-ul. #}
{% if are_trimiteri %} {% if are_trimiteri %}
{% include '_coada.html' %} {% include '_coada.html' %}
{% else %}
<section id="trimiteri-section" hidden></section>
{% endif %} {% endif %}
</div> </div>

View File

@@ -3,7 +3,8 @@
Filtre + tabel (_submissions.html); detaliul se deschide in modalul global (#modal-detaliu). Filtre + tabel (_submissions.html); detaliul se deschide in modalul global (#modal-detaliu).
#} #}
<section id="trimiteri-section" aria-labelledby="trimiteri-heading" <section id="trimiteri-section" aria-labelledby="trimiteri-heading"
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);"> style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);"
{% if oob %}hx-swap-oob="outerHTML"{% endif %}>
<div class="card"> <div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;"> <div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 id="trimiteri-heading" style="font-size:15px; margin:0;"> <h2 id="trimiteri-heading" style="font-size:15px; margin:0;">
@@ -19,45 +20,66 @@
</span> </span>
</div> </div>
<!-- Bara de filtre: vehicul/data + pill-uri de stare pe acelasi rand. Pill-urile scriu <!-- Bara de filtre: [quick-pills data STANGA] [cautare vehicul MIJLOC] [pills stare DREAPTA].
campul hidden status si re-trimit form-ul (filtreazaStare) -> filtrul persista la reincarcari. --> Pill-urile de stare scriu campul hidden status si re-trimit form-ul (filtreazaStare).
Quick-pills de data apeleaza setDataRange -> seteaza data_de/data_pana + re-submit. -->
<form id="filtre-trimiteri" <form id="filtre-trimiteri"
hx-get="/_fragments/submissions" hx-get="/_fragments/submissions"
hx-target="#submissions-wrap" hx-target="#submissions-wrap"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-trigger="submit, change, keyup delay:400ms from:input[name='vehicul']" hx-trigger="submit, change, keyup delay:400ms from:input[name='vehicul']"
style="display:flex; gap:10px 14px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;"> style="display:flex; gap:8px 12px; flex-wrap:wrap; align-items:center; margin-bottom:12px;">
<input type="hidden" id="f-status" name="status" value="{{ status_filtru | default('', true) }}"> <input type="hidden" id="f-status" name="status" value="{{ status_filtru | default('', true) }}">
{# Pagina curenta — actualizata prin OOB swap din _submissions.html; inclusa la reincarcari. #} {# Pagina curenta — actualizata prin OOB swap din _submissions.html; inclusa la reincarcari. #}
<input type="hidden" id="f-page" name="page" value="1"> <input type="hidden" id="f-page" name="page" value="1">
<div>
<label for="f-vehicul" class="muted" style="display:block; font-size:12px;">Vehicul (nr/VIN)</label> {# === STANGA: Quick-pills de data (preset interval) + buton Custom ===
<input id="f-vehicul" type="text" name="vehicul" placeholder="ex. B123 sau VIN" style="max-width:180px;"> Azi / 7 zile / 30 zile → seteaza interval preset si submitr automat.
Custom → dezvaluie #custom-date-fields pentru introducere manuala (fara submit automat). #}
<div style="flex:0 0 auto; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<div class="pills-categorii" id="quick-date-pills">
<button type="button" class="pill-cat pill-data" data-range="azi"
aria-pressed="false"
onclick="setDataRange(this,'azi')">Azi</button>
<button type="button" class="pill-cat pill-data" data-range="7zile"
aria-pressed="false"
onclick="setDataRange(this,'7zile')">7 zile</button>
<button type="button" class="pill-cat pill-data" data-range="30zile"
aria-pressed="false"
onclick="setDataRange(this,'30zile')">30 zile</button>
<button type="button" class="pill-cat pill-data" data-range="custom"
aria-pressed="false"
onclick="setDataRange(this,'custom')">Custom</button>
</div>
{# Campuri de data pentru modul Custom: ascunse pana la click pe „Custom".
type="date" (nu hidden) permite interactiunea utilizatorului.
Campul change pe form re-incarca automat lista via hx-trigger="change". #}
<div id="custom-date-fields"
style="display:none; gap:4px; align-items:center; flex-wrap:wrap; font-size:13px;">
<label for="f-data-de" class="muted" style="font-size:12px; white-space:nowrap;">De:</label>
<input type="date" id="f-data-de" name="data_de" value=""
style="font-size:13px; max-width:140px;">
<label for="f-data-pana" class="muted" style="font-size:12px; white-space:nowrap;">Pana:</label>
<input type="date" id="f-data-pana" name="data_pana" value=""
style="font-size:13px; max-width:140px;">
</div>
</div> </div>
<div>
<label for="f-data-de" class="muted" style="display:block; font-size:12px;">Data de la</label> {# === MIJLOC: cautare vehicul (nr/VIN) + buton Filtreaza === #}
<input id="f-data-de" type="date" name="data_de"> <div style="display:flex; align-items:center; gap:8px; flex:1 1 auto; min-width:160px; flex-wrap:wrap;">
<input id="f-vehicul" type="text" name="vehicul" placeholder="Vehicul (nr/VIN)"
style="flex:1 1 auto; min-width:120px;">
<button type="submit" style="flex:0 0 auto;">Filtreaza</button>
</div> </div>
<div>
<label for="f-data-pana" class="muted" style="display:block; font-size:12px;">pana la</label> {# === DREAPTA: pill-uri de stare cu contoare; re-randate via OOB la reincarcarea tabelului === #}
<input id="f-data-pana" type="date" name="data_pana"> <span id="pills-categorii" class="pills-categorii" style="margin-left:auto; flex:0 0 auto;">
</div>
<button type="submit">Filtreaza</button>
{# Pill-uri de stare pe acelasi rand cu filtrele; re-randate prin OOB la reincarcarea tabelului. #}
<span id="pills-categorii" class="pills-categorii" style="margin-left:auto;">
{% include '_pills.html' %} {% include '_pills.html' %}
</span> </span>
</form> </form>
<!-- Nudge "Date noi": tabelul nu se reimprospateaza singur; bannerul apare doar cand <!-- Tabelul se reincarca la: incarcarea paginii, actiunile tale (trimiteriChanged)
pollerul usor detecteaza schimbari, iar utilizatorul reincarca cand vrea. --> si auto-refresh periodic din poller (date noi externe). -->
<div id="nudge-trimiteri" hidden role="status" aria-live="polite">
<span>Sunt trimiteri actualizate.</span>
<button type="button" onclick="reincarcaTrimiteri()">Reincarca</button>
</div>
<!-- Tabelul se reincarca DOAR la: incarcarea paginii, actiunile tale (trimiteriChanged)
sau apasarea pe Reincarca (reincarcaTrimiteri). Fara poll periodic care sa-l reseteze. -->
<div id="submissions-wrap" <div id="submissions-wrap"
hx-get="/_fragments/submissions" hx-get="/_fragments/submissions"
hx-trigger="load, trimiteriChanged from:body, reincarcaTrimiteri" hx-trigger="load, trimiteriChanged from:body, reincarcaTrimiteri"

View File

@@ -1,23 +1,6 @@
{# Macro-uri partajate intre template-urile de import si mapari. #} {# Macro-uri partajate intre template-urile de import si mapari. #}
{# Comutator COMPACT "In coada" — Manual <-> Auto pe un singur rand. {# US-002 (PRD 5.11): autosend_toggle neutralizat — auto_send nu mai tine randuri (US-001).
INVARIANT BACKEND: control = checkbox cu `name="auto_send" value="true"` si Simbolul pastrat (apelat in _mapari.html, _preview_import.html, _trimitere_detaliu.html)
SEMANTICA DE PREZENTA (bifat -> trimite "true" -> True; nebifat -> camp absent -> False). dar intoarce string gol. Coloanele DB raman (default 1, ne-citite pentru hold). #}
E singura forma compatibila cu AMBELE parsere: `Form(bool)` la /mapari SI `bool(form.get())` {% macro autosend_toggle(form_id='', checked=True, label='') -%}{%- endmacro %}
la /_import/.../mapare-operatie. Radio Auto/Manual cu value="false" ar trimite campul prezent
pe "Manual" -> `bool("false")` = True la import (regresie tacuta). De aceea comutator vizual
Manual<->Auto peste checkbox, NU doua radio-uri.
- form_id: leaga input-ul de un <form> extern (necesar in celulele de tabel).
- checked: starea STOCATA per mapare — bifat = Auto. #}
{% macro autosend_toggle(form_id='', checked=True, label='') -%}
<label class="autosend-toggle"
title="Bifat = Auto: pune automat in coada la fisierele viitoare cu aceasta operatie. Nebifat = Manual: tine pentru verificare; nimic nu pleaca la RAR pana confirmi."
style="display:inline-flex; align-items:center; justify-content:center; gap:8px; min-height:36px; cursor:pointer;">
{%- if label %}<span class="muted" style="font-size:13px;">{{ label }}</span>{% endif %}
<input type="checkbox" name="auto_send" value="true"
{%- if form_id %} form="{{ form_id }}"{% endif %}
{%- if checked %} checked{% endif %}
aria-label="In coada automat (Auto) pentru aceasta operatie"
style="width:18px; height:18px; cursor:pointer; accent-color:var(--accent);">
</label>
{%- endmacro %}

View File

@@ -32,7 +32,6 @@
<th>Operatie</th> <th>Operatie</th>
<th>Sugestii</th> <th>Sugestii</th>
<th>Cod RAR</th> <th>Cod RAR</th>
<th>In coada</th>
<th></th> <th></th>
</tr></thead> </tr></thead>
<tbody> <tbody>
@@ -69,9 +68,6 @@
{% endfor %} {% endfor %}
</select> </select>
</td> </td>
<td data-eticheta="In coada">
{{ ui.autosend_toggle(form_id="map-rez-" ~ loop.index, checked=True) }}
</td>
<td> <td>
<button type="submit" form="map-rez-{{ loop.index }}">Salveaza</button> <button type="submit" form="map-rez-{{ loop.index }}">Salveaza</button>
</td> </td>
@@ -107,7 +103,6 @@
<thead><tr> <thead><tr>
<th>Operatie</th> <th>Operatie</th>
<th>Cod RAR</th> <th>Cod RAR</th>
<th>In coada</th>
<th>Actiuni</th> <th>Actiuni</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
@@ -139,9 +134,6 @@
{% endfor %} {% endfor %}
</select> </select>
</td> </td>
<td data-eticheta="In coada">
{{ ui.autosend_toggle(form_id="map-salv-" ~ loop.index, checked=m.auto_send) }}
</td>
<td style="text-align:right; white-space:nowrap;" data-eticheta="Actiuni"> <td style="text-align:right; white-space:nowrap;" data-eticheta="Actiuni">
{# Butoane icon mereu vizibile (fara kebab). SVG aria-hidden; aria-label pe buton. {# Butoane icon mereu vizibile (fara kebab). SVG aria-hidden; aria-label pe buton.
data-dirty-form e citit de JS din base.html: la schimbarea select-ului din acelasi rand, data-dirty-form e citit de JS din base.html: la schimbarea select-ului din acelasi rand,
@@ -182,8 +174,6 @@
O regula leaga orice operatie al carei text <strong>contine</strong> (nu egal, ci substring) O regula leaga orice operatie al carei text <strong>contine</strong> (nu egal, ci substring)
un cuvant de un cod RAR. Util pentru operatii fara cod intern: ex. orice operatie care un cuvant de un cod RAR. Util pentru operatii fara cod intern: ex. orice operatie care
<em>contine</em> „verificare" primeste codul ales. Match insensibil la majuscule/diacritice. <em>contine</em> „verificare" primeste codul ales. Match insensibil la majuscule/diacritice.
<strong>In coada</strong>: implicit oprit — regula rezolva codul dar tine randul pentru
verificare umana pana activezi „In coada".
</p> </p>
{% if not text_rules %} {% if not text_rules %}
@@ -198,7 +188,6 @@
<thead><tr> <thead><tr>
<th>Daca operatia contine</th> <th>Daca operatia contine</th>
<th>Cod RAR</th> <th>Cod RAR</th>
<th>In coada</th>
<th>Actiuni</th> <th>Actiuni</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
@@ -216,9 +205,6 @@
<td class="muted" style="font-size:12px;" data-eticheta="Cod RAR"> <td class="muted" style="font-size:12px;" data-eticheta="Cod RAR">
{{ r.cod_prestatie }} {{ r.cod_prestatie }}
</td> </td>
<td class="muted" style="font-size:12px;" data-eticheta="In coada">
{% if r.auto_send %}Auto (in coada){% else %}Manual (verificare){% endif %}
</td>
<td style="text-align:right; white-space:nowrap;"> <td style="text-align:right; white-space:nowrap;">
<button type="submit" form="rt-del-{{ loop.index }}" <button type="submit" form="rt-del-{{ loop.index }}"
style="background:var(--card); color:var(--err); border-color:var(--err);"> style="background:var(--card); color:var(--err); border-color:var(--err);">
@@ -251,16 +237,13 @@
{% endfor %} {% endfor %}
</select> </select>
</td> </td>
<td data-eticheta="In coada">
{{ ui.autosend_toggle(form_id="rt-add", checked=False) }}
</td>
<td style="text-align:right; white-space:nowrap;"> <td style="text-align:right; white-space:nowrap;">
<button type="submit" form="rt-add">Adauga</button> <button type="submit" form="rt-add">Adauga</button>
</td> </td>
</tr> </tr>
{# Preview pre-salvare: cate operatii nemapate potriveste pattern-ul. #} {# Preview pre-salvare: cate operatii nemapate potriveste pattern-ul. #}
<tr> <tr>
<td colspan="4" style="padding-top:0;"> <td colspan="3" style="padding-top:0;">
<div id="rt-preview" aria-live="polite"></div> <div id="rt-preview" aria-live="polite"></div>
</td> </td>
</tr> </tr>

View File

@@ -17,25 +17,25 @@
</div> </div>
{% endif %} {% endif %}
<!-- Rezumat stari (id stabil pentru OOB swap dupa editarea unui rand) --> <!-- Rezumat stari cu etichete umane cu majuscula (id stabil pentru OOB swap) -->
{% set status_labels = [ {% set status_labels = [
('ok', 'gata de trimis'), ('ok', 'Gata de trimis'),
('needs_review', 'verifica valori'), ('needs_review', 'Verifica valori'),
('needs_mapping', 'fara cod RAR'), ('needs_mapping', 'Cod RAR lipsa'),
('needs_data', 'date lipsa'), ('needs_data', 'Date incomplete'),
('already_sent', 'deja trimis'), ('already_sent', 'Deja trimis'),
('duplicate_in_file','dublicat in fisier'), ('duplicate_in_file','Duplicat in fisier'),
] %} ] %}
<div id="preview-rezumat" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;"> <div id="preview-rezumat" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
{% for status_key, label in status_labels %} {% for status_key, label in status_labels %}
{%- set cnt = summary.get(status_key, 0) -%} {%- set cnt = summary.get(status_key, 0) -%}
{% if cnt > 0 %} {% if cnt > 0 %}
<span class="pill s-{{ status_key }}">{{ cnt }} {{ label }}</span> <span class="pill s-{{ status_key }}">{{ cnt }} {{ label | lower }}</span>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
<!-- Butoane filtrare stare --> <!-- Butoane filtrare stare — text uman, data-filter pastreaza codul tehnic -->
<div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:10px;" role="group" <div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:10px;" role="group"
aria-label="Filtrare dupa stare"> aria-label="Filtrare dupa stare">
<button type="button" class="filter-btn" data-filter="all" <button type="button" class="filter-btn" data-filter="all"
@@ -48,7 +48,7 @@
<button type="button" class="filter-btn" data-filter="{{ status_key }}" <button type="button" class="filter-btn" data-filter="{{ status_key }}"
style="min-height:36px; font-size:13px; padding:4px 12px; style="min-height:36px; font-size:13px; padding:4px 12px;
background:transparent; border-color:var(--line); color:var(--ink);"> background:transparent; border-color:var(--line); color:var(--ink);">
{{ status_key }} ({{ cnt }}) {{ label }} ({{ cnt }})
</button> </button>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@@ -96,9 +96,6 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="mapcol">
{{ ui.autosend_toggle(checked=True, label="In coada automat") }}
</div>
<div class="mapcol"> <div class="mapcol">
<button type="submit" style="min-height:44px;">Salveaza</button> <button type="submit" style="min-height:44px;">Salveaza</button>
</div> </div>
@@ -107,23 +104,23 @@
</div> </div>
{% endif %} {% endif %}
<!-- Tabel preview. Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form, <!-- Tabel preview in format identic cu tabelul Trimiteri (.tabel-trimiteri).
Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form,
altfel Enter intr-un camp ar declansa trimiterea ireversibila). Bifele altfel Enter intr-un camp ar declansa trimiterea ireversibila). Bifele
needs_review se asociaza la #confirm-form prin atributul form=. --> needs_review se asociaza la #confirm-form prin atributul form=. -->
<div class="tablewrap"> <div class="tablewrap tabel-trimiteri">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>#</th> <th class="col-id">#</th>
<th>VIN</th> <th class="col-stare">Stare</th>
<th>Nr. Inm.</th> <th class="col-vehicul">Vehicul</th>
<th>Data</th> <th class="col-operatie">Operatie</th>
<th>KM final</th> <th class="col-data">Data</th>
<th>Operatie</th> <th class="col-km">KM final</th>
<th>Stare</th> <th class="col-note">Note</th>
<th>Note</th> <th class="col-verificat">Verificat?</th>
<th>Verificat?</th> <th class="col-actiuni">Actiuni</th>
<th>Actiuni</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -132,6 +129,11 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<!-- Mesaj "filtrat la zero": afisat de JS cand filtrul ascunde toate randurile -->
<p id="preview-zero-message" class="muted"
style="display:none; text-align:center; padding:24px 16px; font-size:14px;">
Niciun rand nu corespunde filtrului selectat.
</p>
</div> </div>
<!-- Bara confirmare (sticky jos) — singurul formular care trimite la RAR --> <!-- Bara confirmare (sticky jos) — singurul formular care trimite la RAR -->
@@ -240,11 +242,17 @@
if (btn) btn.disabled = (total === 0) || editing; if (btn) btn.disabled = (total === 0) || editing;
} }
/* Filtrare randuri dupa stare */ /* Filtrare randuri dupa stare.
Cand niciun rand nu e vizibil, afiseaza mesajul #preview-zero-message. */
function filterRows(status) { function filterRows(status) {
var visible = 0;
document.querySelectorAll('tbody tr[data-status]').forEach(function(tr) { document.querySelectorAll('tbody tr[data-status]').forEach(function(tr) {
tr.style.display = (status === 'all' || tr.dataset.status === status) ? '' : 'none'; var show = status === 'all' || tr.dataset.status === status;
tr.style.display = show ? '' : 'none';
if (show) visible++;
}); });
var zeroMsg = document.getElementById('preview-zero-message');
if (zeroMsg) zeroMsg.style.display = (visible === 0) ? '' : 'none';
document.querySelectorAll('.filter-btn').forEach(function(b) { document.querySelectorAll('.filter-btn').forEach(function(b) {
var active = b.dataset.filter === status; var active = b.dataset.filter === status;
b.style.background = active ? 'var(--accent)' : ''; b.style.background = active ? 'var(--accent)' : '';

View File

@@ -1,34 +1,41 @@
{# {#
_preview_rand.html — un singur rand de preview import. _preview_rand.html — un singur rand de preview import.
Doua moduri: Doua moduri:
- display (editing falsy): <tr> normal + buton "Editeaza" pe coloana de actiuni. - display (editing falsy): <tr> normal cu 9 coloane in format .tabel-trimiteri.
- edit (editing truthy): <tr> cu un singur <td colspan> ce contine un FORM PROPRIU - edit (editing truthy): <tr class="preview-edit"> (display:block) cu un singur
(NU #confirm-form) cu grila responsiva refolosita din _trimitere_detaliu.html. <td> ce contine un FORM PROPRIU (NU #confirm-form). Escapa grila table-layout:fixed.
Swap pe RAND (hx-target pe #preview-row-N, outerHTML), NU pe #import-section. Swap pe RAND (hx-target pe #preview-row-N, outerHTML), NU pe #import-section.
La save, optional OOB pe rezumat + contor "gata de trimis" (include_oob). La save, optional OOB pe rezumat + contor "gata de trimis" (include_oob).
Campuri pre-computate de _web_compute_preview (NOT din template raw):
row.prez — prezentare_din_payload(resolved): vehicul_nr, vin_scurt,
operatie, cod_rar, data_prestatie, odometru
row.stare_eticheta — text uman (ex. "Gata de trimis"), din STARI_PREVIEW
row.stare_css — clasa CSS (ex. "s-ok"), din STARI_PREVIEW
row.nota_umana — mesaj uman formatat pentru coloana Note (fara repr Python)
#} #}
{%- set res = row.resolved -%} {%- set res = row.resolved -%}
{%- set status = row.resolved_status -%} {%- set status = row.resolved_status -%}
{%- set prestatii = res.get('prestatii') or [] -%}
{%- set op = (prestatii[0].get('cod_prestatie') or prestatii[0].get('cod_op_service', '')) if prestatii else '' -%}
{% if editing %} {% if editing %}
{%- set err_map = {} -%} {%- set err_map = {} -%}
{%- set fix_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 -%} {%- 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"> <tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}" data-editing="1"
<td colspan="10" style="background:rgba(91,141,239,.06);"> class="preview-edit">
<td data-eticheta="" style="padding:0; border:none;">
<form class="rand-editare" <form class="rand-editare"
hx-post="/_import/{{ import_id }}/rand/{{ row.row_index }}/editeaza" hx-post="/_import/{{ import_id }}/rand/{{ row.row_index }}/editeaza"
hx-target="#preview-row-{{ row.row_index }}" hx-target="#preview-row-{{ row.row_index }}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-indicator="#rand-spinner-{{ row.row_index }}" hx-indicator="#rand-spinner-{{ row.row_index }}"
hx-disabled-elt="find button" hx-disabled-elt="find button"
hx-on::response-error="this.querySelector('.rand-eroare-banner').style.display='block';"> 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 '' }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:8px;"> <div style="display:flex; align-items:center; gap:8px; margin-bottom:8px;">
<strong style="font-size:13px;">Editare rand {{ row.row_index + 1 }}</strong> <strong style="font-size:13px;">Editare rand {{ row.row_index + 1 }}</strong>
<span class="pill s-{{ status }}" style="font-size:11px;">{{ status }}</span> <span class="pill {{ row.stare_css }}" style="font-size:11px;">{{ row.stare_eticheta }}</span>
</div> </div>
{% if message %} {% if message %}
@@ -91,22 +98,37 @@
{%- 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 }}"
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="muted">{{ row.row_index + 1 }}</td> <td class="col-id muted" data-eticheta="#">{{ row.row_index + 1 }}</td>
<td>{{ res.get('vin') or '<span class="muted"></span>' | safe }} <td class="col-stare" data-eticheta="Stare">
{% if disp_fix_map.get('vin') %}<span class="camp-fix">{{ disp_fix_map.get('vin') }}</span>{% endif %} <span class="pill {{ row.stare_css }}">{{ row.stare_eticheta }}</span>
</td> </td>
<td>{{ res.get('nr_inmatriculare') or '' }} <td class="col-vehicul" data-eticheta="Vehicul">
{{ row.prez.vehicul_nr }}
{% if row.prez.vin_scurt and row.prez.vin_scurt != '—' %}
<div class="muted" style="font-size:12px;">{{ row.prez.vin_scurt }}</div>
{% endif %}
{# 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('nr_inmatriculare') %}<span class="camp-fix">{{ disp_fix_map.get('nr_inmatriculare') }}</span>{% endif %} {% if disp_fix_map.get('nr_inmatriculare') %}<span class="camp-fix">{{ disp_fix_map.get('nr_inmatriculare') }}</span>{% endif %}
</td> </td>
<td>{{ res.get('data_prestatie') or '' }} <td class="col-operatie" data-eticheta="Operatie">
<div>{{ row.prez.operatie }}</div>
{% if row.prez.cod_rar and row.prez.cod_rar != '—' %}
<div class="cod-rar-sub"><span class="cod-rar-cod">{{ row.prez.cod_rar }}</span></div>
{% else %}
<div class="muted cod-rar-sub">nemapat</div>
{% endif %}
</td>
<td class="col-data" data-eticheta="Data prestatie">
{{ row.prez.data_prestatie }}
{% if disp_fix_map.get('data_prestatie') %}<span class="camp-fix">{{ disp_fix_map.get('data_prestatie') }}</span>{% endif %} {% if disp_fix_map.get('data_prestatie') %}<span class="camp-fix">{{ disp_fix_map.get('data_prestatie') }}</span>{% endif %}
</td> </td>
<td>{{ res.get('odometru_final') or '' }} <td class="col-km" data-eticheta="KM final">
{{ row.prez.odometru }}
{% if disp_fix_map.get('odometru_final') %}<span class="camp-fix">{{ disp_fix_map.get('odometru_final') }}</span>{% endif %} {% if disp_fix_map.get('odometru_final') %}<span class="camp-fix">{{ disp_fix_map.get('odometru_final') }}</span>{% endif %}
</td> </td>
<td>{{ op or '<span class="muted"></span>' | safe }}</td> <td class="col-note" data-eticheta="Note"
<td><span class="pill s-{{ status }}">{{ status }}</span></td> style="font-size:12px; white-space:normal;">
<td class="muted" style="font-size:12px; white-space:normal; max-width:220px;">
{% if status == 'already_sent' and row.get('already_sent_info') %} {% if status == 'already_sent' and row.get('already_sent_info') %}
{% set ai = row.already_sent_info %} {% set ai = row.already_sent_info %}
deja trimis {{ (ai.get('created_at') or '')[:10] }} deja trimis {{ (ai.get('created_at') or '')[:10] }}
@@ -114,20 +136,11 @@
{% elif status == 'duplicate_in_file' and row.get('duplicate_with') %} {% elif status == 'duplicate_in_file' and row.get('duplicate_with') %}
dubla cu randul dubla cu randul
{% for idx in row.duplicate_with %}{{ idx + 1 }}{% if not loop.last %}, {% endif %}{% endfor %} {% for idx in row.duplicate_with %}{{ idx + 1 }}{% if not loop.last %}, {% endif %}{% endfor %}
{% elif row.flags %} {% else %}
{{ row.flags[0] }} {{ row.nota_umana or '' }}
{% elif row.errors %}
{%- for e in row.errors -%}
{%- if e is mapping -%}
{{ e.get('message') or e.get('msg') or (e.values() | list | first) }}
{%- else -%}
{{ e }}
{%- endif -%}
{%- if not loop.last %}; {% endif -%}
{%- endfor -%}
{% endif %} {% endif %}
</td> </td>
<td style="text-align:center;"> <td class="col-verificat" data-eticheta="Verificat?" style="text-align:center;">
{% if status == 'needs_review' %} {% if status == 'needs_review' %}
<label class="chk" style="min-height:44px; justify-content:center; cursor:pointer;" <label class="chk" style="min-height:44px; justify-content:center; cursor:pointer;"
title="Bifat inseamna ca ai verificat valorile si le incluzi in trimitere"> title="Bifat inseamna ca ai verificat valorile si le incluzi in trimitere">
@@ -138,7 +151,7 @@
</label> </label>
{% endif %} {% endif %}
</td> </td>
<td 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;
@@ -154,13 +167,13 @@
{% if include_oob %} {% if include_oob %}
{# OOB: actualizeaza rezumatul si contorul "gata de trimis" dupa save, fara a re-randa sectiunea. #} {# OOB: actualizeaza rezumatul si contorul "gata de trimis" dupa save, fara a re-randa sectiunea. #}
{% set status_labels = [ {% set status_labels = [
('ok','gata de trimis'), ('needs_review','verifica valori'), ('needs_mapping','fara cod RAR'), ('ok','Gata de trimis'), ('needs_review','Verifica valori'), ('needs_mapping','Cod RAR lipsa'),
('needs_data','date lipsa'), ('already_sent','deja trimis'), ('duplicate_in_file','dublicat in fisier')] %} ('needs_data','Date incomplete'), ('already_sent','Deja trimis'), ('duplicate_in_file','Duplicat in fisier')] %}
<div id="preview-rezumat" hx-swap-oob="true" <div id="preview-rezumat" hx-swap-oob="true"
style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;"> style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
{% for status_key, label in status_labels %} {% for status_key, label in status_labels %}
{%- set cnt = summary.get(status_key, 0) -%} {%- set cnt = summary.get(status_key, 0) -%}
{% if cnt > 0 %}<span class="pill s-{{ status_key }}">{{ cnt }} {{ label }}</span>{% endif %} {% if cnt > 0 %}<span class="pill s-{{ status_key }}">{{ cnt }} {{ label | lower }}</span>{% endif %}
{% 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>

View File

@@ -1,7 +1,8 @@
<div id="status-bar" class="status-bar card" <div id="status-bar" class="status-bar card"
hx-get="/_fragments/status" hx-get="/_fragments/status?tab={{ tab_activ | default('acasa') }}"
hx-trigger="every 15s" hx-trigger="every 15s, trimiteriChanged from:body"
hx-swap="outerHTML"> hx-swap="outerHTML"
{% if oob %}hx-swap-oob="outerHTML"{% endif %}>
<!-- Cont in asteptare de activare (regasit din vechiul _banner; mereu vizibil) --> <!-- Cont in asteptare de activare (regasit din vechiul _banner; mereu vizibil) -->
{% if not account_active %} {% if not account_active %}
@@ -49,4 +50,20 @@
{# Pill-urile de stare s-au mutat in bara de filtre din sectiunea Trimiteri (_coada.html). #} {# Pill-urile de stare s-au mutat in bara de filtre din sectiunea Trimiteri (_coada.html). #}
{# === Rand 3: navigatie rapida sub contoare (US-005) ===
Linkurile Trimiteri + Mapari apar pe FIECARE pagina sub status-bar.
Marcajul activ vine din variabila de context tab_activ (transmisa de dashboard via ?tab=
sau default 'acasa'). Badge-ul Mapari = mapari_badge (aceeasi sursa: counts.needs_mapping).
#}
{% set _tab = tab_activ | default('acasa') %}
<nav class="status-nav" aria-label="Navigatie rapida"
style="margin-top:10px; display:flex; gap:8px 16px; flex-wrap:wrap; font-size:13px; border-top:1px solid var(--line); padding-top:8px;">
<a href="/"
{% if _tab == 'acasa' or _tab == '' %}aria-current="page"{% endif %}
class="status-nav-link{% if _tab == 'acasa' or _tab == '' %} status-nav-activ{% endif %}">Trimiteri</a>
<a href="/?tab=mapari"
{% if _tab == 'mapari' %}aria-current="page"{% endif %}
class="status-nav-link{% if _tab == 'mapari' %} status-nav-activ{% endif %}">Mapari{% if mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:16px; height:16px; margin-left:4px; padding:0 4px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ mapari_badge }}</span>{% endif %}</a>
</nav>
</div> </div>

View File

@@ -69,7 +69,6 @@
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
{{ ui.autosend_toggle(checked=True) }}
<button type="submit">Salveaza maparea</button> <button type="submit">Salveaza maparea</button>
</div> </div>
</form> </form>

View File

@@ -148,21 +148,20 @@
font-size:12px; font-weight:600; cursor:pointer; background:transparent; font-size:12px; font-weight:600; cursor:pointer; background:transparent;
border:1.5px solid var(--line); color:var(--muted); min-height:30px; border:1.5px solid var(--line); color:var(--muted); min-height:30px;
transition:background .15s, color .15s; } transition:background .15s, color .15s; }
.pill-cat:hover { filter:brightness(1.1); } /* Hover: color-mix pe culoarea curenta a pill-ului (categoria sa), nu filter:brightness
(care producea rosu plin ilizibil pe pill-uri colorate). Activ suprima hover-ul. */
.pill-cat:hover { background:color-mix(in srgb, currentColor 12%, transparent); }
.pill-cat:focus-visible { outline:2px solid var(--accent); outline-offset:2px; } .pill-cat:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
.pill-cat-n { font-size:11px; font-weight:700; color:var(--card); padding:0 5px; .pill-cat-n { font-size:11px; font-weight:700; color:var(--card); padding:0 5px;
border-radius:99px; min-width:18px; text-align:center; } border-radius:99px; min-width:18px; text-align:center; }
/* Activ categorie: umplere cu culoarea categoriei (currentColor = var injectat inline) */
.pill-cat[aria-pressed="true"] { background:currentColor; color:var(--card); border-color:currentColor; } .pill-cat[aria-pressed="true"] { background:currentColor; color:var(--card); border-color:currentColor; }
.pill-cat[aria-pressed="true"] .pill-cat-n { background:var(--card) !important; color:currentColor; } .pill-cat[aria-pressed="true"] .pill-cat-n { background:var(--card) !important; color:currentColor; }
/* Activ suprima hover: pastram culoarea activa, nu o mixam din nou */
.pill-cat[aria-pressed="true"]:hover { background:currentColor; }
/* Reset "Toate" activ = --accent plin (nu culoarea categoriei) */
.pill-cat-reset[aria-pressed="true"] { background:var(--accent); color:#fff; border-color:var(--accent); } .pill-cat-reset[aria-pressed="true"] { background:var(--accent); color:#fff; border-color:var(--accent); }
/* Nudge "Date noi": apare doar cand pollerul usor detecteaza schimbari; tabelul nu se .pill-cat-reset[aria-pressed="true"]:hover { background:var(--accent); }
schimba singur niciodata, utilizatorul reincarca cand vrea. */
#nudge-trimiteri { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin:0 0 12px;
padding:8px 12px; border-radius:8px; font-size:13px;
border:1px solid var(--accent);
background:color-mix(in srgb, var(--accent) 12%, var(--card)); }
#nudge-trimiteri[hidden] { display:none; }
#nudge-trimiteri button { font-size:13px; padding:5px 12px; min-height:32px; }
.s-queued{color:var(--accent);} .s-sending{color:var(--warn);} .s-sent{color:var(--ok);} .s-queued{color:var(--accent);} .s-sending{color:var(--warn);} .s-sent{color:var(--ok);}
.s-error,.s-needs_data,.s-needs_mapping{color:var(--err);} .s-error,.s-needs_data,.s-needs_mapping{color:var(--err);}
.s-ok{color:var(--ok);} .s-ok{color:var(--ok);}
@@ -267,6 +266,41 @@
border-radius:6px; cursor:pointer; min-height:36px; white-space:nowrap; } border-radius:6px; cursor:pointer; min-height:36px; white-space:nowrap; }
.kebab-menu button:hover, .kebab-menu a:hover { background:var(--line); } .kebab-menu button:hover, .kebab-menu a:hover { background:var(--line); }
.kebab-menu button.danger { color:var(--err); } .kebab-menu button.danger { color:var(--err); }
/* === Accordion import compact (US-006 — regiune CSS disjuncta) ===
<details id="import-details"> invelete stepper + upload pe Acasa.
Atribut `open` setat de server din `are_trimiteri`:
False (first-run) → open; True (returning) → colapsat.
Degradare fara JS: returning-user colapsat, first-run deschis — ambele corecte
fara toggle JS (un toggle JS pur ar lasa returning-user fara-JS cu ecranul deschis).
aria-expanded + focus: native <details> le gestioneaza automat. */
#import-details { margin-bottom:16px; border:1px solid var(--accent); border-radius:8px;
background:var(--card); overflow:hidden; }
#import-details > summary { display:flex; align-items:center; gap:8px; cursor:pointer;
user-select:none; list-style:none; padding:10px 16px;
font-weight:600; font-size:14px; color:var(--ink); }
#import-details > summary::-webkit-details-marker { display:none; }
#import-details > summary::marker { display:none; }
#import-details > summary::before { content:"▶"; font-size:10px; color:var(--accent);
flex-shrink:0; transition:transform .15s; }
#import-details[open] > summary::before { transform:rotate(90deg); }
#import-details[open] > summary { border-bottom:1px solid var(--line); }
#import-details > summary:hover { background:color-mix(in srgb, var(--accent) 8%, var(--card)); }
#import-details > summary:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
/* Continutul (stepper + card upload): padding si bordul cardului interior sunt suprastate
de bordul exterior al #import-details — scoatem border duplicat de pe .card intern. */
#import-details #import-section { padding:0; }
#import-details #import-section > .card { border-left:none; border-right:none;
border-bottom:none; border-radius:0;
margin-bottom:0; }
/* === Sfarsit regiune accordion import === */
/* === Inceput regiune nav links status-bar (US-005) ===
Linkuri Trimiteri + Mapari sub contoare; marcaj activ cu aria-current/status-nav-activ. */
.status-nav-link { color:var(--accent); text-decoration:none; padding:2px 6px; border-radius:4px;
transition:background .1s; }
.status-nav-link:hover { background:color-mix(in srgb, var(--accent) 10%, transparent); }
.status-nav-link.status-nav-activ { color:var(--ink); font-weight:600; }
.status-nav-link.status-nav-activ:hover { background:color-mix(in srgb, var(--ink) 8%, transparent); }
/* === Sfarsit regiune nav links status-bar === */
/* Tabel cu cautare + paginare client-side (data-dt). Maparile pot creste la sute de randuri; /* Tabel cu cautare + paginare client-side (data-dt). Maparile pot creste la sute de randuri;
filtram/paginez DOM-ul deja randat, fara cereri suplimentare. Vezi scriptul din base.html. */ filtram/paginez DOM-ul deja randat, fara cereri suplimentare. Vezi scriptul din base.html. */
input[type=search] { font:inherit; background:var(--bg); color:var(--ink); border:1px solid var(--line); input[type=search] { font:inherit; background:var(--bg); color:var(--ink); border:1px solid var(--line);
@@ -311,6 +345,25 @@
@media (max-width:1024px) { @media (max-width:1024px) {
.tabel-trimiteri .col-actualizat { display:none; } .tabel-trimiteri .col-actualizat { display:none; }
} }
/* === Preview import: coloane extra fata de tabelul Trimiteri.
SCOPAT prin .tabel-trimiteri (clasa partajata). Regiune separata —
nu atinge coloanele existente (col-chk/id/stare/data/rar/actualizat).
Suma latimi fixe: col-id(48) + col-stare(104) + col-data(104) +
col-km(76) + col-note(176) + col-verificat(80) + col-actiuni(92) = 680px.
Restul (~600px la 1280px) revine lui col-vehicul + col-operatie (fluid). === */
.tabel-trimiteri .col-km { width:76px; }
.tabel-trimiteri .col-note { width:176px; }
.tabel-trimiteri .col-verificat{ width:80px; }
.tabel-trimiteri .col-actiuni { width:92px; }
/* Randul de editare inline iese din grila table-layout:fixed (display:block),
astfel formularul nu e constrans de latimile coloanelor individuale.
Salveaza/Anuleaza sunt mereu vizibile (overflow:visible, nu clip). */
.tabel-trimiteri tr.preview-edit { display:block; }
.tabel-trimiteri tr.preview-edit > td { display:block; width:100%; box-sizing:border-box; padding:0; border:none; }
/* Pe mobil (<768px): pseudo-eticheta goala (data-eticheta="") nu lasa spatiu gol. */
@media (max-width:767px) {
.tabel-trimiteri td[data-eticheta=""]::before { display:none; }
}
/* === Modal detaliu: fereastra modala globala, in afara zonei de poll /* === Modal detaliu: fereastra modala globala, in afara zonei de poll
(#submissions-wrap). Backdrop + dialog centrat pe desktop; focus-trap + (#submissions-wrap). Backdrop + dialog centrat pe desktop; focus-trap +
scroll-lock + inert pe <main> sunt in JS. Varianta full-screen mobil: vezi blocul scroll-lock + inert pe <main> sunt in JS. Varianta full-screen mobil: vezi blocul
@@ -435,12 +488,16 @@
<header> <header>
{# Celula stanga: logo ROMFAST #} {# Celula stanga: logo ROMFAST #}
<div class="header-left"> <div class="header-left">
{# Logo PNG real, RGBA transparent — ok pe toate temele fara filtre. #} {# Logo PNG real, RGBA transparent — ok pe toate temele fara filtre.
<img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo"> Invelit in <a href="/"> pentru a naviga la Trimiteri (Acasa) de pe orice pagina. #}
<a href="/" style="display:inline-flex; align-items:center; text-decoration:none;">
<img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
</a>
</div> </div>
{# Celula centru: titlu + badge env mic #} {# Celula centru: titlu + badge env mic.
Titlul linkeaza la / (Trimiteri) ca si logo-ul. #}
<div class="header-center"> <div class="header-center">
<h1>Gateway RAR AUTOPASS</h1> <a href="/" style="text-decoration:none; color:inherit;"><h1>Gateway RAR AUTOPASS</h1></a>
<span class="env">{{ rar_env }}</span> <span class="env">{{ rar_env }}</span>
</div> </div>
{# Celula dreapta: comutator tema + versiune + meniu cont #} {# Celula dreapta: comutator tema + versiune + meniu cont #}
@@ -457,6 +514,8 @@
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu" aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
aria-label="Meniu cont" title="Meniu cont">&#9776;</button> aria-label="Meniu cont" title="Meniu cont">&#9776;</button>
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden> <div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden>
{# Prima intrare: Trimiteri (Acasa) — pagina principala cu import + lista trimiterilor. #}
<a role="menuitem" href="/">Trimiteri</a>
{# Mapari, cu badge needs_mapping. #} {# Mapari, cu badge needs_mapping. #}
{% set _mapari_badge = (badges.mapari if (badges is defined and badges and badges.mapari) else 0) %} {% set _mapari_badge = (badges.mapari if (badges is defined and badges and badges.mapari) else 0) %}
<a role="menuitem" href="/?tab=mapari">Mapari{% if _mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ _mapari_badge }}</span>{% endif %}</a> <a role="menuitem" href="/?tab=mapari">Mapari{% if _mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ _mapari_badge }}</span>{% endif %}</a>
@@ -800,11 +859,51 @@
})(); })();
</script> </script>
<script> <script>
// Filtrare stare prin pill-uri + reincarcare manuala a tabelului. Tabelul NU se mai // Filtrare stare prin pill-uri + reincarcare a tabelului (manuala sau auto din poller).
// schimba singur (fara poll periodic pe #submissions-wrap): un poller usor verifica // Reincarcarea trece prin form -> pastreaza filtrul/pagina curenta (hx-include).
// doar versiunea datelor si arata nudge-ul "Date noi" cand difera. Reincarcarea
// (pill, nudge sau actiune) trece prin form -> pastreaza filtrul/pagina curenta.
(function() { (function() {
// Quick-pills de data (Azi/7 zile/30 zile/Custom): seteaza interval sau dezvaluie campuri manuale.
// NU modifica f-status — pastreaza pill-ul de stare activ curent.
window.setDataRange = function(btn, range) {
var form = document.getElementById('filtre-trimiteri');
if (!form) return;
var de = document.getElementById('f-data-de');
var pana = document.getElementById('f-data-pana');
var hp = document.getElementById('f-page'); if (hp) hp.value = '1';
var customPanel = document.getElementById('custom-date-fields');
// Marcheaza pill-ul de data activ, reseteaza celelalte quick-pills
document.querySelectorAll('.pill-data').forEach(function(b) {
b.setAttribute('aria-pressed', 'false');
});
if (btn) btn.setAttribute('aria-pressed', 'true');
// Custom: dezvaluie campurile manuale, asteapta inputul utilizatorului.
// NU face submit automat; form-ul submite la change (hx-trigger="change").
if (range === 'custom') {
// Goleste valorile ramase de la ultimul preset — utilizatorul porneste de la curat.
if (de) de.value = '';
if (pana) pana.value = '';
if (customPanel) customPanel.style.display = 'flex';
if (de) de.focus();
return;
}
// Preset-uri: ascunde campurile manuale, seteaza intervalul si trimite imediat.
if (customPanel) customPanel.style.display = 'none';
var azi = new Date();
// Formateaza data ca YYYY-MM-DD in zona locala (nu UTC, ca sa nu cada cu -1 zi noaptea)
function fmt(d) {
return d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
}
var from, to;
if (range === 'azi') { from = fmt(azi); to = fmt(azi); }
else if (range === '7zile') { var d7 = new Date(azi); d7.setDate(d7.getDate() - 6); from = fmt(d7); to = fmt(azi); }
else if (range === '30zile') { var d30 = new Date(azi); d30.setDate(d30.getDate() - 29); from = fmt(d30); to = fmt(azi); }
else { from = ''; to = ''; }
if (de) de.value = from;
if (pana) pana.value = to;
if (form.requestSubmit) form.requestSubmit(); else form.submit();
};
// Pill de stare: scrie campul hidden, reseteaza pagina la 1 si re-trimite filtrul. // Pill de stare: scrie campul hidden, reseteaza pagina la 1 si re-trimite filtrul.
window.filtreazaStare = function(btn, status) { window.filtreazaStare = function(btn, status) {
var form = document.getElementById('filtre-trimiteri'); var form = document.getElementById('filtre-trimiteri');
@@ -817,30 +916,33 @@
if (btn) btn.setAttribute('aria-pressed', 'true'); if (btn) btn.setAttribute('aria-pressed', 'true');
if (form.requestSubmit) form.requestSubmit(); else form.submit(); if (form.requestSubmit) form.requestSubmit(); else form.submit();
}; };
// Reincarca tabelul pastrand filtrul curent (hx-include #filtre-trimiteri) si ascunde nudge-ul. // Reincarca tabelul pastrand filtrul curent (hx-include #filtre-trimiteri).
window.reincarcaTrimiteri = function() { window.reincarcaTrimiteri = function() {
var n = document.getElementById('nudge-trimiteri'); if (n) n.hidden = true;
if (window.htmx) htmx.trigger('#submissions-wrap', 'reincarcaTrimiteri'); if (window.htmx) htmx.trigger('#submissions-wrap', 'reincarcaTrimiteri');
}; };
// Poller "Date noi": compara versiunea datelor cu cea cu care s-a randat tabelul. // Poller auto-refresh: compara versiunea datelor cu cea cu care s-a randat tabelul.
// Daca difera, arata nudge-ul; daca nu, nu atinge nimic. JSON usor, fara re-render. // Daca difera (schimbari externe — ex. worker a procesat trimiteri), reincarca automat
// pastrand filtrul curent. Fara nudge "Date noi" — auto-refresh e mai consistent.
// Decizie: nudge eliminat; distinctia propriu vs extern e imposibila pe client
// fara sesiune dedicata — auto-refresh acoper ambele cazuri (US-008).
var INTERVAL = 20000; var INTERVAL = 20000;
function versiuneCurenta() { function versiuneCurenta() {
var e = document.getElementById('trimiteri-versiune'); var e = document.getElementById('trimiteri-versiune');
return e ? e.getAttribute('data-v') : null; return e ? e.getAttribute('data-v') : null;
} }
var _verifica_in_curs = false;
function verifica() { function verifica() {
if (versiuneCurenta() === null) return; // tabelul nu e pe ecran (alt tab) if (versiuneCurenta() === null) return; // tabelul nu e pe ecran (alt tab)
var nudge = document.getElementById('nudge-trimiteri'); if (_verifica_in_curs) return; // evita suprapuneri
if (!nudge || !nudge.hidden) return; // deja afisat -> nu re-cere _verifica_in_curs = true;
fetch('/_fragments/trimiteri-versiune', { headers: { 'X-Requested-With': 'fetch' } }) fetch('/_fragments/trimiteri-versiune', { headers: { 'X-Requested-With': 'fetch' } })
.then(function(r) { return r.ok ? r.json() : null; }) .then(function(r) { return r.ok ? r.json() : null; })
.then(function(d) { .then(function(d) {
if (!d) return; if (d && d.v !== versiuneCurenta()) reincarcaTrimiteri();
if (d.v !== versiuneCurenta()) nudge.hidden = false;
}) })
.catch(function() {}); .catch(function() {})
.finally(function() { _verifica_in_curs = false; });
} }
setInterval(verifica, INTERVAL); setInterval(verifica, INTERVAL);
})(); })();

View File

@@ -3,8 +3,8 @@
<!-- Bara de status: mereu vizibila --> <!-- Bara de status: mereu vizibila -->
<div id="status-bar" class="status-bar card" <div id="status-bar" class="status-bar card"
hx-get="/_fragments/status" hx-get="/_fragments/status?tab={{ active_tab }}"
hx-trigger="load, every 15s" hx-trigger="load, every 15s, trimiteriChanged from:body"
hx-swap="outerHTML"> hx-swap="outerHTML">
<div class="empty muted" style="padding:8px 0;">se incarca starea…</div> <div class="empty muted" style="padding:8px 0;">se incarca starea…</div>
</div> </div>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,567 @@
<!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/main-autoplan-restore-20260626-095534.md -->
# PRD 5.11 — Import compact + preview in format Trimiteri + navigatie + simplificare auto_send
**Stare**: inchis (2026-06-26 — verify-pass + code-review; vezi RAPORT VERIFY si RAPORT AUTOPLAN)
> 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.
## 1. Obiectiv
Reparam un set de bug-uri si frictiuni UX descoperite la dogfooding pe o baza goala (prima
utilizare), confirmate in browser (E2E Playwright pe `prezentari_test.csv`):
1. **Dupa primul import ramai blocat** — wizard-ul se reseteaza la pasul 1, mesajul „vezi mai jos in
Trimiterile tale" trimite la o sectiune care **nu exista in DOM** la first-run (`trimiteri-section`
absent), iar lista nu apare fara reload manual.
2. **Navigatie infundata** — din `?tab=mapari` / `?tab=jurnal` (si Cont/Integrare/Nomenclator) nu
exista niciun link inapoi la Trimiteri; logo-ul nu e link; meniul hamburger nu are „Trimiteri".
3. **Tabelul de preview (pasul 3) e neingrijit** — overflow orizontal taie coloanele Verificat?/Actiuni
(butonul Editeaza), formularul de editare inline se taie, coloana „Note" scapa un **repr Python brut**
(`[{'cod_op_service': ...}]`) iar „Stare" arata coduri brute (`needs_mapping`/`needs_data`).
4. **`auto_send=0` produce o stare falsa** — un rand mapat (ex. OE-2) apare ca `needs_mapping` cu mesaj
tehnic („cod mapat cu auto_send=0…") si fara actiune; bifa „In coada automat" adauga complexitate si
confuzie. **Decizie utilizator (2026-06-26): scoatem complet conceptul auto_send din UI.**
5. **Randul de filtre arata neuniform** — pill-urile au stil outline diferit de butonul solid Filtreaza
si stau in dreapta lui; la hover deveneau rosu plin si textul devenea ilizibil.
6. **Wizard-ul de import ocupa tot ecranul** — stepper + upload sunt doua carduri mari stivuite.
Toate sunt **UI/UX + o simplificare de mapare** (auto_send). Backendul de trimitere (worker, masina de
stari de trimitere, idempotenta, contract RAR) ramane neatins, cu o singura exceptie controlata:
logica de mapare nu mai „tine" randuri pe `auto_send` (US-001).
## 2. Non-Goals (anti scope-creep)
- **Nu** atingem worker-ul, reconcilierea, idempotenta sau contractul RAR.
- **Nu** schimbam canalul API (`POST /v1/prezentari` / `/valideaza`) — doar UI web + `mapping.py`.
(Campul `auto_send` din payload-ul API, daca exista, ramane acceptat dar ignorat — vezi US-001.)
- **Nu** stergem coloanele DB `operations_mapping.auto_send` / `operation_text_rules.auto_send`
(migrare distructiva inutila) — le lasam cu default `1` si nu le mai citim pentru a tine randuri.
- **Nu** rescriem categoriile de filtrare cu etichetele din referinta (Facturate/Anulate/Diferente etc.)
— pastram categoriile reale autopass (Toate / needs_mapping / needs_data / error). Adoptam doar
**layout-ul si stilul** vizual din referinta (`image.png`).
- **Nu** schimbam fluxul de erori pe 3 niveluri (5.4) — doar consumam helperele existente in preview.
## 3. Stories atomice
> Fiecare story: cea mai mica unitate care lasa sistemul functional. Backend + UI pentru acelasi
> comportament = 2 stories. `Fisiere` + `Depinde de` complete (decid paralelizarea).
### US-001: Scoate „hold pe auto_send" din logica de mapare (backend)
**Ca** operator **vreau** ca o operatie mapata la un cod RAR sa intre direct in coada **pentru ca**
bifa auto_send introducea o stare `needs_mapping` falsa si confuza pe randuri deja corecte.
- **Depinde de**: —
- **Fisiere**: `app/mapping.py`, `app/web/routes.py` (DOUA puncte: `post_mapeaza_inline` ~1018-1069
parsare form `auto_send` + delegare `reresolve_account`; ramura `has_no_auto_send` din
`post_corectie_trimitere` ~1166 — referinta „~1160-1185" din draft arata gresit catre mapeaza),
**`app/api/v1/import_router.py`** (importa `has_no_auto_send` ~linia 48, il foloseste in
`_resolve_row_for_preview` ~233 — OMIS in draft; vezi AC „nu sterge simbolul"),
`tests/test_mapping.py`, `tests/test_web_mapeaza.py`, **`tests/test_t6_auto_send.py` +
`tests/test_text_rule_autosend.py` (de sters/rescris — encodeaza invariantul VECHI; vor deveni RED)**
(~6 fisiere)
- **Test intai (RED)**: `tests/test_mapping.py`
`test_operatie_mapata_intra_in_queued_indiferent_de_autosend`,
`test_regula_text_rezolvata_nu_mai_tine_randul`,
`test_fara_stare_needs_mapping_pe_auto_send_oprit`,
`test_niciun_rand_existent_nu_se_dezgheata` (AC dezghet),
`test_canal_api_auto_send_ignorat_intra_queued` (`classify_prezentare`, `mapping_meta` auto_send=0 → `queued`)
- **Acceptance criteria**:
- [ ] `resolve_prestatii` NU mai marcheaza `regula_fara_autosend` / nu mai produce ramura
`AUTO_SEND_OPRIT`; o operatie cu cod rezolvat (mapare exacta SAU regula text) → `queued`.
- [ ] **`has_no_auto_send` NU se sterge — se neutralizeaza (`return False`) si ramane DEFINIT**
(importat in `routes.py:70` SI `import_router.py:48`; stergerea simbolului → `ImportError` la
load → app nu porneste). „Eliminat" si „intoarce mereu False" NU sunt echivalente: alege a doua.
- [ ] Toate cele 4 callsite-uri tratate: clasificare (`mapping.py` ~413), `reresolve_account`
(~664 + cheia stat `review_manual`), corectie (`routes.py` ~1166), preview (`import_router.py` ~233).
- [ ] `/trimitere/{id}/mapeaza`: dupa mapare, randul cu cod valid trece `queued` (nu `needs_mapping`
cu mesaj `auto_send`); raspuns fara cardul „auto-send oprit".
- [ ] `needs_mapping` ramane DOAR pentru operatii fara cod RAR rezolvat (semantica reala).
- [ ] Coloanele DB raman; `save_mapping` accepta inca `auto_send` (default 1) dar valoarea nu mai
tine randuri (compat migrare). Niciun rand existent nu se „dezgheata" automat fara actiune.
- [ ] **Randuri legacy `needs_mapping`-din-auto_send (cod deja prezent)** raman blocate pe test/prod
fara afordanta de mapare (`_nemapate_pentru_submission` ~881 intoarce `[]`). DECIZIE explicita:
requeue one-time la deploy (`reresolve_account` per cont, acum le trece `queued`) SAU documentam
corectia inline ca singura iesire. (De notat in raportul VERIFY.)
- **Verificare E2E**: `POST /trimitere/{id}/mapeaza` din panoul de detaliu (browser) → rand `queued`.
### US-002: Scoate bifa „In coada automat" din UI (Mapari + preview + detaliu)
**Ca** operator **vreau** sa nu mai vad bifa auto_send nicaieri **pentru ca** nu mai are efect (US-001)
si crea confuzie.
- **Depinde de**: US-001
- **Fisiere**: `app/web/templates/_macros.html` (macro `autosend_toggle`),
`app/web/templates/_mapari.html`, `app/web/templates/_preview_import.html`,
`app/web/templates/_trimitere_detaliu.html`, `tests/test_web_mapari.py` (~5 fisiere)
- **Test intai (RED)**: `tests/test_web_mapari.py`
`test_mapari_fara_toggle_autosend`, `test_preview_panou_mapare_fara_autosend`,
`test_detaliu_mapare_inline_fara_autosend`
- **Acceptance criteria**:
- [ ] Macro-ul `autosend_toggle` eliminat (sau golit) si scos din cele 3 sabloane.
- [ ] Panoul „Operatii de mapat la cod RAR" (preview) si tab-ul Mapari salveaza maparea fara nicio
referinta la „In coada automat" / „auto_send".
- [ ] Coloana „In coada" din tabelul Mapari operatii salvate + reguli text dispare.
- [ ] Niciun text rezidual „auto_send" / „Tine pentru verificare" in sabloane.
- **Verificare E2E**: browser pe `/?tab=mapari` + panoul de mapare din preview — fara bifa.
### US-003: Preview pas 3 — format identic cu tabelul Trimiteri (UI)
**Ca** operator **vreau** ca tabelul de preview sa arate exact ca lista Trimiteri **pentru ca** acum
scapa text intern brut, taie coloane si formularul de editare.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_preview_import.html`, `app/web/templates/_preview_rand.html`,
`app/web/templates/base.html` (refolosire `.tabel-trimiteri` + latimi `col-*` pt coloanele extra),
**`app/web/payload_view.py`** (`prezentare_din_payload` accepta dict → `row.resolved` direct; OMIS),
**stratul de adaptare in builderele de preview** (`app/web/routes.py` `_web_compute_preview` ~1851
SI `app/api/v1/import_router.py` `_resolve_row_for_preview` ~122) — construieste un view-model tip
`prez` + traduce `resolved_status`→eticheta, OMIS in draft,
`app/web/labels.py` (NU reutilizare directa — vezi AC; necesita un map de stari PREVIEW),
`tests/test_preview_import.py` (~6 fisiere)
- **Test intai (RED)**: `tests/test_preview_import.py`
`test_preview_nu_contine_repr_python` (fixture cu rand `needs_mapping`/unmapped REAL — altfel trece in gol),
`test_preview_stare_eticheta_umana` (acopera `ok`/`needs_review`/`already_sent`/`duplicate_in_file`),
`test_preview_foloseste_clasa_tabel_trimiteri`
- **Acceptance criteria**:
- [ ] **NU reutiliza `eticheta_stare`/`eticheta_scurta` direct: ridica `KeyError` pe starile de
preview** (`ok`/`needs_review`/`already_sent`/`duplicate_in_file` — absente in `STARI_SUBMISSION`).
Adauga un map de stari preview→eticheta umana (extinde `labels.py` sau map nou), inclusiv text
pentru `already_sent`/`duplicate_in_file`.
- [ ] **NU pasa `row.errors` (lista Python) in `motiv_uman`/`parse_erori`** (asteapta string JSON →
`str()` + `json.loads` esueaza → fallback `raw[:160]` = ACELASI repr pe care US-003 il repara).
Stratul de adaptare serializeaza erorile (`json.dumps`) sau formateaza uman inainte de render.
- [ ] Tabelul de preview foloseste `.tabel-trimiteri`, fara overflow la 1280px (`scrollWidth <= clientWidth`).
Necesita: `data-eticheta` pe TOATE celulele (carduri <768px le citesc via `td::before`) +
latimi `col-*` pt cele 4 coloane extra (Verificat?/Actiuni/Note/etc.) sub `table-layout:fixed`.
- [ ] Coloana Note" mesaj uman, **niciodata** repr Python (`[{'cod_op_service': ...}]`).
- [ ] Stare" pill uman (ca la Trimiteri), nu cod brut `needs_mapping`/`needs_data`.
- [ ] Vehicul (nr + VIN sub), operatie (+ cod RAR sub) randate via view-model `prez` (din `payload_view`).
- [ ] Formularul inline `colspan` e **full-width sub rand** dar IESE din grila `table-layout:fixed`
(rand `display:block` sau editor in afara tabelului fix), cu Salveaza/Anuleaza mereu vizibile.
- [ ] Stare filtrat la zero" (filter JS din `_preview_import.html` ascunde toate randurile) afiseaza
un mesaj, nu tabel gol mut; filtrul coexista cu cardurile `td{display:block}`.
- **Verificare E2E**: browser pasul 3 coloane intregi, fara repr brut, editare necliata, mobil + desktop.
### US-004: Randul de filtre Trimiteri — layout + stil ca referinta (UI)
**Ca** operator **vreau** un rand de filtre uniform si lizibil **pentru ca** pill-urile aveau alt stil
decat butonul Filtreaza, stateau in dreapta si la hover deveneau rosu ilizibil.
- **Depinde de**:
- **Fisiere**: `app/web/templates/_coada.html`, `app/web/templates/_pills.html`,
`app/web/templates/base.html` (CSS `.pill-cat` + hover + layout), `tests/test_web_filtre.py` (~4 fisiere)
- **Test intai (RED)**: `tests/test_web_filtre.py`
`test_pill_uri_in_stanga_controalelor`, `test_pill_categorie_stil_uniform`,
`test_quick_pills_data_seteaza_interval`
- **Acceptance criteria**:
- [ ] Layout ca `image.png`: pill-uri rapide de data (Azi / 7 zile / 30 zile / Custom) in STANGA,
camp de cautare la mijloc, pill-uri de stare cu contoare la dreapta un singur stil de pill.
- [ ] Pill-urile NU mai stau `margin-left:auto` izolate la dreapta butonului.
- [ ] **Stare activa, dezambiguizata**: pill-ul Toate" (reset) = `--accent` plin; pill-urile de
CATEGORIE = culoarea lor de categorie cand sunt active (NU toate accent accent plin" din
draft contrazice schema per-categorie din `_pills.html`). Enumera token-ul de prim-plan per
categorie ca sa garantezi AA, SAU foloseste o pereche fixa (alb pe saturat) cu valori verificate.
- [ ] **Hover lizibil, regula explicita**: hover = `background:color-mix(in srgb, currentColor 12%,
transparent)` (nu `filter:brightness` actual, nu rosu plin); pill-ul ACTIV suprima hover-ul.
Acelasi mecanism pe reset si pe categorie.
- [ ] **Focus pastrat**: inelul `:focus-visible` (outline accent) ramane pe toate cele 3 variante de
pill dupa rescrierea CSS (US-004 atinge exact acest bloc — usor de pierdut).
- [ ] Quick-pills de data seteaza `data_de`/`data_pana` (preset) si reincarca lista (HTMX), pastrand
pill-ul de stare activ.
- [ ] Functioneaza in Light/Dark/Petrol cu token-uri concrete enumerate (nu „verifica AA" abstract).
- **Verificare E2E**: browser — comutare quick-pill data + stare, hover citibil pe toate temele.
### US-005: Link-uri de navigatie sub contoare pe toate paginile (UI)
**Ca** operator **vreau** sa ajung oricand inapoi la Trimiteri / Mapari **pentru ca** din Mapari/Jurnal
nu mai aveam cale de intoarcere.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/base.html` (header + `#cont-menu`), **AMBELE** `dashboard.html`
SI `_status.html` (ambele randeaza `<div id="status-bar">` — pe cai diferite: full-page vs OOB/partial;
„sau" din draft risca nav prezenta la load dar pierduta la refresh + duplicat de id),
`tests/test_web_nav.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_nav.py` —
`test_nav_trimiteri_mapari_pe_mapari`, `test_nav_trimiteri_pe_jurnal`, `test_logo_linkeaza_acasa`
- **Acceptance criteria**:
- [ ] Sub contoarele din `#status-bar`, pe FIECARE pagina (acasa/mapari/jurnal/cont/integrare/
nomenclator): rand de link-uri `Trimiteri` + `Mapari` (cu badge needs_mapping daca exista),
cu marcaj activ pe pagina curenta. **Sursa marcajului activ = o variabila de context numita
explicit (ex. `tab_activ`, comparata cu `?tab=`), nu ghicita de implementator.**
- [ ] Badge-ul `needs_mapping` reutilizeaza O SINGURA sursa (acelasi camp ca `_mapari_badge` din
hamburger / `blocate_total`), nu un al doilea contor calculat diferit.
- [ ] Logo-ul ROMFAST + titlul linkeaza la `/` (Trimiteri).
- [ ] Meniul hamburger capata si „Trimiteri" (Acasa) ca prima intrare.
- [ ] Niciun deadlock: din orice tab se ajunge la Trimiteri intr-un click.
- **Verificare E2E**: browser — din Mapari si Jurnal, un click → Trimiteri.
### US-006: Import = container compact colapsabil (UI)
**Ca** operator **vreau** ca importul sa nu ocupe tot ecranul **pentru ca** stepper-ul + upload-ul mare
stau in cale dupa ce am deja trimiteri.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_acasa.html`, `app/web/templates/_upload.html`,
`app/web/templates/_stepper.html`, `app/web/templates/base.html` (CSS/JS accordion),
`tests/test_web_acasa.py` (~4 fisiere)
- **Test intai (RED)**: `tests/test_web_acasa.py` —
`test_import_colapsat_cand_are_trimiteri`, `test_import_deschis_la_first_run`
- **Acceptance criteria**:
- [ ] Stepper + upload intr-UN singur container („Importa un fisier"), nu doua carduri.
- [ ] Colapsat implicit cand contul are deja trimiteri (`are_trimiteri=True`); auto-deschis la
first-run (`are_trimiteri=False`).
- [ ] In timpul fluxului (mapcoloane/preview) containerul ramane deschis (nu se inchide intre pasi).
- [ ] **Implementare cu `<details>` nativ** (disclosure CSS-only): serverul seteaza atributul `open`
din `are_trimiteri`, deci „fara JS → degradare la deschis" si „colapsat la returning" sunt
ambele corecte fara toggle JS (un toggle JS pur ar lasa returning-user-ul fara-JS cu ecranul
permanent deschis = exact bug-ul reparat). `aria-expanded`/focus pastrate.
- [ ] Ordinea de stivuire definita pe cele 4 combinatii (`are_creds` × `are_trimiteri`) — accordion,
„Primii pasi", `trimiteri-section` nu se suprapun la first-run-cu-creds.
- **Verificare E2E**: browser — first-run deschis; dupa ce exista trimiteri, colapsat; click → extins.
### US-007: Dupa commit, lista Trimiteri apare + se reimprospateaza automat (UI + glue)
**Ca** operator **vreau** sa-mi vad trimiterile imediat dupa import **pentru ca** acum mesajul trimite la
o sectiune inexistenta si nimic nu se actualizeaza fara reload.
- **Depinde de**: US-006
- **Fisiere**: `app/web/routes.py` (`web_confirma_import` ~2757 intoarce AZI doar `_upload.html` via
`hx-target=#import-section, outerHTML` — un swap `outerHTML` pe `#import-section` NU poate materializa
un frate `#trimiteri-section`; alege mecanismul de dezvaluire — vezi AC), `app/web/templates/_upload.html`,
`app/web/templates/_acasa.html`, `app/web/templates/_coada.html`, **`app/web/templates/_status.html` +
`dashboard.html`** (OOB pe `#status-bar` — e in afara lui `acasa-section`, nu se actualizeaza la
re-randarea `_acasa`/`_upload`), `tests/test_import_commit.py` (~6 fisiere)
- **Test intai (RED)**: `tests/test_import_commit.py` —
`test_commit_raspuns_contine_trimiteri_section`,
`test_commit_raspuns_seteaza_hx_trigger_trimiteriChanged` (asertie pe header literal),
`test_first_run_dupa_commit_arata_lista`,
`test_commit_actualizeaza_status_bar` (OOB contoare)
- **Acceptance criteria**:
- [ ] **Mecanism de dezvaluire ales explicit**: fie (a) re-randam `_acasa.html` complet si retargetam
pe `#acasa-section`, fie (b) emitem un OOB swap care injecteaza `_coada.html`. Minimal fata de
codul actual (return `_upload.html`-only) = **(b)**. La first-run `#submissions-wrap` proaspat
injectat isi declanseaza singur `hx-trigger="load"`.
- [ ] **Raspunsul seteaza `HX-Trigger: trimiteriChanged`** (lipseste azi pe confirma; cf.
`post_mapeaza_inline:1066`) — altfel la returning-user (sectiune deja in DOM) randurile noi
`queued` nu apar. Atentie la dublu-load daca re-randam si `_acasa` (inofensiv, de notat).
- [ ] Containerul de import se colapseaza, iar lista Trimiteri se reincarca automat si arata randurile.
- [ ] **Contoarele `#status-bar` se actualizeaza via OOB swap** (sunt in `_status.html`/`dashboard.html`,
in afara `acasa-section` — re-randarea `_acasa` nu le atinge), imediat, nu la poll-ul de 15s.
- [ ] Mesajul de succes ramane onest („S-au pus in coada N prezentari") si pointeaza la o sectiune
care chiar exista.
- **Verificare E2E**: browser, baza goala — import → commit → lista apare + contoare actualizate, fara reload.
### US-008: Auto-refresh dupa actiuni, fara „Reincarca" manual (UI)
**Ca** operator **vreau** ca lista sa se actualizeze singura dupa ce actionez **pentru ca** butonul
manual „Reincarca" (nudge „Date noi") trecea neobservat (ex. dupa ce am mapat un cod si s-a trimis la RAR).
- **Depinde de**: US-007
- **Fisiere**: `app/web/templates/base.html` (JS poller/nudge), `app/web/templates/_coada.html`,
`app/web/templates/_submissions.html`, `tests/test_web_refresh.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_refresh.py` —
`test_actiune_proprie_reincarca_automat`, `test_nudge_nu_mai_blocheaza_actualizarea`
- **Acceptance criteria**:
- [ ] Dupa o actiune proprie (mapare inline, corectie, repune, commit import) lista se reincarca
automat (fara click pe „Reincarca").
- [ ] Poll-ul de fundal: cand detecteaza date noi declansate de actiuni proprii, aplica refresh
automat; nudge-ul manual ramane doar pentru schimbari externe (alt proces/worker) — sau se
elimina daca devine redundant. (Decizie de implementare documentata in raport.)
- [ ] Nu se reseteaza filtrul/pagina curenta la auto-refresh (pastreaza `#filtre-trimiteri`).
- **Verificare E2E**: browser — mapare inline a unui cod → randul devine `queued`/`sent` fara click manual.
## 4. Riscuri
- **R1 — Scoaterea auto_send rastoarna „default-ul de siguranta CEO"** (reguli text / mapari tineau randul
pentru verificare). Acceptat constient de utilizator (2026-06-26): mapped → queued direct. Mitigare:
randurile genuin nemapate raman `needs_mapping` (nu pleaca), iar preview-ul are gate per-rand
(„Verificat?") inainte de commit. Documentam in CLAUDE.md (invariantul auto_send) ca a fost retras.
- **R2 — Preview in format Trimiteri**: tabelul Trimiteri presupune un view-model (vehicul/op/cod RAR);
randurile de preview vin din alt drum (`_resolve_row_for_preview`). Risc de divergenta de campuri.
Mitigare: refolosim `payload_view` / `labels` ca pe canalul Trimiteri; test anti-repr.
- **R3 — Accordion + HTMX**: colapsarea nu trebuie sa ascunda pasii in timpul fluxului mapcoloane/preview.
Mitigare: container deschis cat timp `import-section` randeaza un pas != upload slim; test dedicat.
- **R4 — Regresie tabel**: `.tabel-trimiteri` are reguli responsive (carduri <768px) — preview-ul are
coloane diferite (Verificat?/Actiuni). Mitigare: verificare E2E mobil + desktop.
## 5. Intrebari deschise
> Rezolvate cu utilizatorul la planificare (2026-06-26) — vezi raspunsurile incorporate mai sus:
> Q1 filtre = **layout complet ca referinta** + fix hover lizibil; Q2 preview = **format ca Trimiteri**;
> Q3 = **scoatem complet bifa auto_send** (Mapari + Trimiteri); Q4 = **acordeon compact + lista apare/
> refresh automat dupa commit** + link-uri Trimiteri/Mapari sub contoare.
- (rezolvat) Pastram coloanele DB `auto_send`? → DA, default 1, ne-citite pentru hold (non-distructiv).
- (deschis, minor) Nudge-ul „Date noi" pentru schimbari externe: il pastram redus sau il eliminam? →
decizie la implementare (US-008), documentata in raport.
## 6. Valuri de executie (graful de dependente)
```
Val 1 (paralel, fisiere disjuncte):
[US-001] mapping.py + routes mapeaza (backend)
[US-003] preview format Trimiteri (_preview_*.html, base.css)
[US-004] filtre layout+stil+hover (_coada/_pills/base.css)
[US-005] navigatie sub contoare (base.html/_status/dashboard)
[US-006] import accordion compact (_acasa/_upload/_stepper)
Val 2 (deblocate de Val 1):
[US-002] scoate toggle auto_send din UI (dep US-001; _macros/_mapari/_preview/_detaliu)
[US-007] post-commit reveal+refresh (dep US-006; routes/_acasa/_upload/_coada)
Val 3:
[US-008] auto-refresh dupa actiuni (dep US-007; base.js/_coada/_submissions)
```
Atentie la fisiere fierbinti partajate intre stories (serializare de catre lead):
`base.html` (US-004 CSS, US-005 header/menu, US-006 accordion JS) si `_coada.html` /
`_preview_import.html` apar in mai multe stories — NU paralel pe acelasi fisier (vezi ROADMAP §5.5).
---
## Raport VERIFY
> Completat de subagentul verificator (context curat, 2026-06-26) in faza VERIFY — ROADMAP §5.6.
> Executie: echipa de teammates Sonnet (lead orchestreaza, NU scrie cod), 6 runde TDD pe valuri cu
> fisiere disjuncte; `base.html`/`routes.py`/`_coada.html`/`_status.html` serializate ca fisiere fierbinti.
**VERDICT GLOBAL: PASS → verify-pass / CLOSE.** 8/8 stories trec criteriile de acceptare, cu dovezi
cod + teste + randare runtime (verificatorul a pornit aplicatia reala si a inspectat HTML-ul randat).
- **Suita**: `python3 -m pytest -q` → **929 passed, 1 skipped** (skip = `test_live_rar`, gated corect
pe marker `live` + `AUTOPASS_LIVE_RAR`). Smoke boot `python3 -c "import app.main"` → OK (critic US-001:
`has_no_auto_send` pastrat → fara ImportError). (Dupa adaugarea testelor cu nomenclatura PRD pe US-008:
931 passed.)
- **US-001** PASS — `has_no_auto_send`→`return False` (definit + importat in routes.py:72 si
import_router.py:48); `resolve_prestatii` nu mai marcheaza `regula_fara_autosend`; ramura
`AUTO_SEND_OPRIT` scoasa din classify; cele 4 callsite tratate; canal API auto_send=0 → `queued`;
`needs_mapping` doar pt unmapped real; coloane DB raman; teste vechi rescrise (nu sterse). Decizie
randuri legacy: `reresolve_account` le re-trece `queued` la deploy (documentat).
- **US-002** PASS — macro `autosend_toggle` golit, simbol pastrat; **0** reziduuri auto_send /
„In coada automat" in template-uri (grep) + 0 in dashboard-ul randat.
- **US-003** PASS — `STARI_PREVIEW` map nou in `labels.py` (fara KeyError pe stari preview);
`nota_umana_preview` (errors ca lista → fara repr brut); view-model `prez` din `payload_view` (accepta
dict); `.tabel-trimiteri` + `col-*` + `data-eticheta` pe toate celulele + editor `tr.preview-edit`
(scapa grila fixa) + mesaj „filtrat la zero".
- **US-004** PASS — quick-pills data STANGA / cautare MIJLOC / pills stare DREAPTA; hover
`color-mix(currentColor 12%)`, `:focus-visible` accent, categorie activa=`currentColor`, reset=`--accent`,
activ suprima hover. (Nota minora: butonul „Custom" — vezi mai jos.)
- **US-005** PASS — nav Trimiteri+Mapari in `_status.html` randat pe fiecare pagina (dashboard.html
incarca fragmentul, fara duplicat de id), activ din `tab_activ`, badge din sursa unica
`counts.needs_mapping`; logo+titlu link `/`; hamburger „Trimiteri" prima. Confirmat runtime
(`aria-current="page"` pe Mapari la `?tab=mapari`).
- **US-006** PASS — `<details id="import-details" {open if not are_trimiteri}>` nativ (fara toggle JS);
runtime: first-run `open` + placeholder `#trimiteri-section`; returning colapsat + lista plina.
- **US-007** PASS — `web_confirma_import` → `_upload` slim + OOB `_coada` (#trimiteri-section) + OOB
`_status` (#status-bar) + header `HX-Trigger: trimiteriChanged`; `#submissions-wrap` se auto-incarca;
mesaj de succes onest catre o sectiune care chiar exista.
- **US-008** PASS — actiuni proprii emit `trimiteriChanged` → reincarcare; poller compara `data-v` si
cheama `reincarcaTrimiteri()` cu `hx-include="#filtre-trimiteri"` (pastreaza filtru+pagina); nudge
„Date noi" ELIMINAT (decizie documentata: distinctia propriu-vs-extern nu e posibila pe client).
- **E2E**: PROBAT la nivel de aplicatie reala (uvicorn :8011, login sesiune, HTML randat real pentru
accordion/nav/logo/filtre/zero-reziduu auto_send). NEPROBAT: click-through Playwright complet pe
upload→preview→commit si testul live RAR `FINALIZATA` (gated, fara creds web in mediu) — acelasi
status ca livrabilele anterioare; backend trimitere neatins, risc minim.
- **Regresia de aur**: PASS la nivel de teste — `test_api` (POST /v1/prezentari) + `test_import_e2e`
(import→commit→queued) + worker reconcile/rar_errors verzi; worker/idempotenta/`build_key`/contract RAR
NEATINSE (non-goals respectate).
**Note minore (non-blocante, follow-up):**
1. US-004 „Custom": AC enumera 4 quick-pills (Azi/7zile/30zile/**Custom**); doar 3 randate ca butoane
(JS-ul suporta ramura `'custom'`, dar fara buton). De adaugat butonul SAU scos „Custom" din AC.
2. Cod/comentarii vestigiale in `import_router.py` (`_motiv_clasificare` ramura `needs_mapping`/auto_send
acum inaccesibila; comentarii care inca spun „auto_send gate"). Pur cosmetic.
---
<!-- ============================================================= -->
<!-- /autoplan REVIEW REPORT — generat 2026-06-26, commit 412102b -->
<!-- Codex indisponibil (usage limit) -> dual voices = subagent-only -->
<!-- ============================================================= -->
# RAPORT AUTOPLAN
## Faza 1 — CEO (strategie & scope)
### 0A. Provocarea premiselor
- **Premisa centrala (US-001): "auto_send hold = stare falsa".** Citind `mapping.py:252-254` si `434-447`, hold-ul era un **gate de siguranta intentionat**, nu un bug. Reformulare onesta: scoatem un gate de siguranta pentru ca frictiunea > riscul de ireversibilitate perceput. **GATE PREMISA: utilizatorul a confirmat (2026-06-26) scoaterea completa** — premisa acceptata constient, documentata in R1.
- Premisa "preview-ul are gate Verificat? care compenseaza" e PARTIALA: gate-ul exista doar pe calea import web; canalul API + remaparea inline (`routes.py:1166`) trec direct in `queued`. Acceptat de utilizator la gate.
- Restul premiselor (overflow tabel, repr Python in Note, hover rosu ilizibil, nav infundata, wizard mare, reveal post-commit rupt) = **confirmate in cod** (vezi Faza 3).
### 0B. Ce exista deja (mapare sub-probleme -> cod)
| Sub-problema | Cod existent de refolosit |
|---|---|
| Etichete umane stare/motiv | `labels.py: eticheta_stare, motiv_uman, parse_erori` |
| Viewmodel rand trimitere | `payload_view.py` + `r.prez.*` in `_submissions.html` |
| Tabel responsive | `.tabel-trimiteri` + carduri <768px in `base.html` |
| Pills stare + contoare | `_pills.html` + `filtreazaStare()` in `base.html` |
| Refresh lista | HTMX `trimiteriChanged from:body` (deja cablat in `_coada.html`) |
| Sectiune trimiteri | `#trimiteri-section` exista in `_coada.html` (doar conditionat de `are_trimiteri`) |
### 0C. Dream-state delta
- ACUM: 8 frictiuni de first-run, una mascand o decizie de siguranta ireversibila.
- ACEST PLAN: first-run curat (import -> preview ingrijit -> commit -> lista apare/refresh), nav fara fundaturi, mapare fara concept auto_send.
- IDEAL 12 luni: un **E2E smoke de first-run** ca poarta de release (codifica scriptul de dogfooding) ca aceste 8 simptome sa nu reapara. **Recomandare deferata (vezi TODOS).**
### 0C-bis. Alternative (US-001)
| Abordare | Efort | Risc | Verdict |
|---|---|---|---|
| Scoatere completa (ales) | mic | gate pierdut pe API/inline | **ALES de utilizator** |
| Hold doar pe text-rules unattended | mic | minim (pastreaza net unde conteaza) | respins de utilizator la gate |
| Default per-cont (ca `on_unmapped_error_default`) | mediu | reversibil, dar pastreaza complexitate | respins de utilizator |
### 0D-0F. Mod = SELECTIVE EXPANSION. Premisa confirmata la gate. Procedam.
### Voci duale CEO (subagent-only; Codex indisponibil)
**CLAUDE SUBAGENT (CEO):** 11 constatari. Critice: (F1) US-001 e o decizie clasa-siguranta impachetata cu UX cosmetic — risc de aprobare-prin-asociere; (F6) regret 6 luni = o regula text gresita auto-trimite FINALIZATA terminal nerecuperabil. Hi: F3 (premisa laundering), F4 (mitigare R1 supraevaluata — nu acopera canalul API), F5 (cazul periculos real = text-rules unattended), F8 (alternativa default-per-cont neanalizata), F10 (over-bundling). Recomandare subagent: split US-001/002 din restul. **Utilizatorul a ales full removal la gate — F4/F5/F6 raman ca follow-up optional, nu blocant.**
```
CEO DUAL VOICES — CONSENSUS:
Dimensiune Claude Codex Consensus
1. Premise valide? Partial N/A flag (gate trecut de user)
2. Problema corecta? Da N/A confirmat de 1 voce
3. Scope calibrat? Nu(F10) N/A flag (over-bundle, dar waves ok)
4. Alternative explorate? Nu(F8) N/A flag (rezolvat la gate)
5. Riscuri piata/legal acoperite? Partial N/A flag (R1 acceptat)
6. Traiectorie 6 luni sanatoasa? Partial N/A flag (F6 -> TODOS)
Codex = N/A (usage limit). Single-critical din 1 voce = flagat oricum.
```
### NU in scope (confirmat)
Worker / reconciliere / idempotenta / contract RAR / canal API (payload) / coloane DB auto_send (raman, default 1) / categorii filtrare (raman cele reale autopass) / fluxul de erori 3-niveluri.
### Registru Erori & Salvare (relevant acestui plan)
| Suprafata | Esec | Salvare existenta |
|---|---|---|
| Import upload | fisier invalid / multi-foaie | `eroare_upload` / select foaie (`_upload.html`) |
| Preview rand | cod necunoscut nomenclator | promovat la `cod_op_service` -> `needs_mapping` (nu se trimite raw) |
| Commit | rand `needs_mapping` | exclus din commit (ramane in lista) |
### Sumar CEO
Plan sanatos ca directie. Singurul flag strategic (US-001 fara control compensator pe calea unattended) **acceptat constient la gate**. US-003-008 = castiguri de first-run cu risc redus; US-007/008 cele mai sigure. Recomandare: pastram PRD-ul unit (waves deja serializeaza fisierele fierbinti), adaugam 1 item TODOS (E2E first-run + control optional text-rule).
## Faza 2 — Design (UI scope: DA)
### Voci duale Design (subagent-only; Codex indisponibil)
**CLAUDE SUBAGENT (design):** constatari grounded in template-uri reale.
- **F2.4 (CRITIC):** `eticheta_stare`/`eticheta_scurta` ridica `KeyError` pe starile de preview
(`ok`/`needs_review`/`already_sent`/`duplicate_in_file`) — reutilizarea „ca la Trimiteri" crapa preview-ul. → US-003 AC.
- **F3.1 (CRITIC):** „activa = accent plin" contrazice pill-urile colorate per-categorie (`_pills.html:10`).
Reset = accent; categorie = culoarea ei. → US-004 AC.
- **F2.1/F2.2/F2.3 (high):** celulele de preview n-au `data-eticheta` (carduri <768px ilizibile); coloanele
extra n-au latimi `col-*` sub `table-layout:fixed`; editorul `colspan` mosteneste latimea care depaseste. → US-003 AC.
- **F3.2/F3.3 (high):** token-uri de contrast/hover neenumerate per tema/categorie — cel mai mare risc de „haunt". → US-004 AC.
- **F2.6 (high):** preview n-are view-model `prez`; adaptorul lipseste din Fisiere. → US-003 Fisiere.
- **F4.1/F4.2 (high):** `#status-bar` definit in DOUA locuri; variabila de tab activ nenumita. → US-005 Fisiere+AC.
- **F1.3 (medium):** degradarea fara-JS contrazice „colapsat la returning" — necesita `<details>` nativ. → US-006 AC.
```
DESIGN LITMUS — CONSENSUS:
Dimensiune Claude Codex Consensus
1. Ierarhie info (accordion) ok* N/A confirmat (1 voce) *cu state-table
2. Stari lipsa (preview) NU N/A flag CRITIC (F2.4) -> US-003
3. Matrice pill hover/active/focus NU N/A flag CRITIC (F3.1) -> US-004
4. Nav activ + badge Partial N/A flag (F4) -> US-005
5. Specificitate AC Partial N/A flag (token-uri, view-model)
Codex = N/A (usage limit).
```
## Faza 3 — Eng (arhitectura, teste, risc)
### Diagrama de dependente (componente noi vs existente)
```
[import upload] -> _upload.html (#import-section, outerHTML swap)
| US-006: invelit in <details> (open=are_trimiteri)
v
[web_confirma_import ~2757] --returneaza--> _upload.html (AZI: doar atat -> bug US-007)
US-007 fix: + OOB inject _coada.html (#trimiteri-section) + HX-Trigger trimiteriChanged
+ OOB #status-bar (_status.html / dashboard.html)
|
v
[#submissions-wrap] --hx-trigger: load, trimiteriChanged from:body--> /_fragments/submissions
| |
v v
_submissions.html (r.prez view-model) _pills.html (OOB) <- US-004 CSS
[mapping.py resolve_prestatii] --auto_send hold SCOS (US-001)--> status: queued | needs_mapping(real)
has_no_auto_send: NEUTRALIZAT (return False), simbol PASTRAT (importat in routes.py + import_router.py)
callsite-uri: clasificare ~413 | reresolve ~664 | corectie routes ~1166 | preview import_router ~233
[preview builders] _web_compute_preview ~1851 / _resolve_row_for_preview ~122
US-003: + adaptor resolved_status -> eticheta umana + view-model prez (payload_view) + json.dumps(errors)
```
### Voci duale Eng (subagent-only; Codex indisponibil)
**CLAUDE SUBAGENT (eng):** 13 constatari verificate in cod.
- **F1 (HIGH/boot-crash):** `import_router.py:48` importa `has_no_auto_send`, `:233` il foloseste — al 4-lea
callsite, OMIS din US-001 Fisiere. Stergerea simbolului → `ImportError` → app nu porneste. „Eliminat" ≠ „return False". → US-001.
- **F4 (HIGH):** `test_t6_auto_send.py` + `test_text_rule_autosend.py` encodeaza holdul vechi → devin RED, neprogramate. → US-001.
- **F5/F6 (HIGH):** `motiv_uman`/`parse_erori` asteapta string JSON; `row.errors` (lista) → fallback `raw[:160]` = ACELASI repr. Necesita strat de adaptare in buildere. → US-003.
- **F2 (medium):** referinta de linie „~1160-1185" arata gresit; sunt 2 puncte (mapeaza ~1032 form, corectie ~1166). → US-001.
- **F3 (medium):** randuri legacy needs_mapping-din-auto_send raman fara afordanta (`_nemapate_pentru_submission` ~881 → `[]`). → US-001 AC dezghet.
- **F7 (medium):** `payload_view.py` necesar dar omis din US-003 Fisiere. → US-003.
- **F8/F9/F10 (HIGH):** US-007 — `outerHTML` pe `#import-section` nu materializeaza frate `#trimiteri-section`; lipseste `HX-Trigger`; `#status-bar` (alt fisier) nu se actualizeaza. → US-007.
- **F11/F13 (low):** waves §6 supravand paralelismul pe `base.html` (3 stories, regiuni disjuncte → serializate de lead); `save_mapping` scrie auto_send=0 dupa US-002 (inofensiv cat e ignorat).
```
ENG DUAL VOICES — CONSENSUS:
Dimensiune Claude Codex Consensus
1. Arhitectura sanatoasa? Da N/A confirmat (1 voce)
2. Acoperire teste suficienta? NU N/A flag (F4 + gap-uri) -> test plan
3. Riscuri performanta? ok N/A nimic flagat (batch lookup already_sent fara N+1)
4. Securitate? ok N/A nimic nou (CSRF/auth neatinse de UI)
5. Cai de eroare tratate? Partial N/A flag (boot-crash F1, KeyError F5/F6)
6. Risc de deploy gestionabil? Partial N/A flag (randuri legacy F3)
Codex = N/A (usage limit).
```
### Test plan (artefact pe disc)
`~/.gstack/projects/romfast-rar-autopass/main-5.11-test-plan-20260626.md` — 18 codepath-uri mapate la acoperire,
suite de rulat (inclusiv smoke de boot `import app.main` dupa US-001), 3 gap-uri critice.
### Registru moduri de esec (cu flag-uri critice)
| Mod de esec | Trigger | Gravitate | Mitigare in plan |
|---|---|---|---|
| App nu porneste | stergerea `has_no_auto_send` | CRITIC | US-001 AC: neutralizare, nu stergere + smoke boot |
| Preview crapa la render | reutilizare `eticheta_stare` pe stari preview | CRITIC | US-003 AC: map de stari preview |
| Repr Python reapare in Note | `row.errors` in `motiv_uman` | HIGH | US-003 AC: adaptor json.dumps |
| Returning-user nu vede randuri noi | lipsa `HX-Trigger` pe confirma | HIGH | US-007 AC: header trimiteriChanged |
| Randuri legacy blocate fara iesire | needs_mapping-din-auto_send pe prod | MEDIUM | US-001 AC: requeue one-time SAU doc corectie |
## Cross-phase themes
- **Theme: vocabularul de stari preview ≠ submission** — flagat INDEPENDENT in Faza 2 (Design F2.4) si
Faza 3 (Eng F5/F6). Semnal de incredere mare: US-003 nu poate reutiliza direct `labels.py`; necesita
un strat de traducere + view-model. Cea mai importanta corectie a planului.
- **Theme: Fisiere sub-dimensionate fata de AC** — US-001 (import_router), US-003 (payload_view + buildere),
US-007 (_status.html) — toate omiteau fisiere pe care propriile AC le cer. Corectat in stories.
## Corectie graf de executie (§6)
Wave 1 NU e complet paralel: `base.html` e atins de US-004 (CSS pill), US-005 (header/nav), US-006
(`<details>`) — regiuni disjuncte, dar lead-ul le SERIALIZEAZA (3 edituri pe acelasi fisier). Paralelismul
real al Wave 1 e mai mic decat anunta graful. `_coada.html` (US-004→007→008) e serializat oricum de dependente.
## Deferate la TODOS.md
- E2E smoke de first-run ca poarta de release (CEO F2).
- Control compensator optional pe auto-trimitere unattended (CEO F5/F6) — risc rezidual acceptat de user.
<!-- AUTONOMOUS DECISION LOG -->
## Decision Audit Trail
| # | Faza | Decizie | Clasificare | Principiu | Rationament | Respins |
|---|---|---|---|---|---|---|
| 1 | CEO | Premisa auto_send: full removal | GATE (user) | — | Utilizatorul a confirmat la gate | hold text-rule-only; default per-cont |
| 2 | CEO | Pastram PRD unit (nu split US-001/002) | Taste | P3/P6 | Waves serializeaza deja; pre-launch, risc real ~0 | split in 2 PRD-uri (subagent F1/F10) |
| 3 | CEO | E2E first-run -> TODOS, nu in scope acum | Mechanical | P3 | Boil-lake separat, nu blocheaza 5.11 | inclus in 5.11 |
| 4 | Eng | US-001: neutralizare has_no_auto_send, NU stergere | Mechanical | P5 | Stergerea = ImportError (import_router) = boot crash | stergere simbol |
| 5 | Eng | US-001 Fisiere += import_router.py + teste vechi | Mechanical | P1/P2 | Callsite real + teste care devin RED | lasa Fisiere ca-n draft |
| 6 | Eng | US-001: randuri legacy -> decizie requeue/doc | Taste | P1 | In blast radius; impact pe prod DB | ignora (lasa blocate tacit) |
| 7 | Design+Eng | US-003: strat adaptare + map stari preview | Mechanical | P1 | Reutilizare directa crapa/repr — cross-phase theme | reutilizare directa labels |
| 8 | Design+Eng | US-003 Fisiere += payload_view + buildere | Mechanical | P1/P2 | AC cere view-model prez inexistent pe calea preview | doar template+labels |
| 9 | Design | US-003: data-eticheta + col-* + colspan escape | Mechanical | P1 | Altfel overflow/mobil rup (AC propriu) | doar clasa .tabel-trimiteri |
| 10 | Design | US-004: reset=accent, categorie=culoare proprie | Mechanical | P5 | „accent plin" contrazice schema per-categorie | toate accent |
| 11 | Design | US-004: token-uri hover/active/focus enumerate | Mechanical | P1/P5 | „verifica AA" abstract = haunt | lasa la implementator |
| 12 | Design | US-005: AMBELE status-bar + var tab_activ | Mechanical | P5 | „sau" risca nav pierduta la refresh + dup id | un singur fisier |
| 13 | Design | US-006: `<details>` nativ | Mechanical | P5 | Toggle JS pur rupe degradarea fara-JS | toggle JS |
| 14 | Eng | US-007: OOB inject _coada + HX-Trigger + OOB status-bar | Mechanical | P1/P5 | outerHTML nu materializeaza frate; lipsa trigger | „include/dezvaluie" vag |
## Stare review
- Faza 1 CEO: rulat (subagent-only). Premisa confirmata la gate.
- Faza 2 Design: rulat (subagent-only). 2 critice -> AC corectate.
- Faza 3 Eng: rulat (subagent-only). 1 boot-crash + cross-phase theme -> stories corectate.
- Faza 3.5 DX: SARIT — fara suprafata developer-facing (planul exclude canalul API; UI = operator).
- Codex: indisponibil toata sesiunea (usage limit pana Jul 18) -> toate vocile = subagent-only.

View File

@@ -129,9 +129,10 @@ def test_badge_trimiteri_scoped_pe_acasa(client):
def test_trimiteri_fara_poll_periodic_pe_tabel(client): def test_trimiteri_fara_poll_periodic_pe_tabel(client):
"""Tabelul de trimiteri NU se mai reimprospateaza periodic: #submissions-wrap se """Tabelul de trimiteri NU se reimprospateaza periodic: #submissions-wrap se
incarca la load / actiunile utilizatorului / Reincarca (nudge), fara `every Ns`. incarca la load / actiunile utilizatorului (trimiteriChanged) / reincarcaTrimiteri.
Reimprospatarea live se face prin nudge-ul "Date noi" + endpointul de versiune.""" Reimprospatarea automata la date noi externe se face prin pollerul de versiune
care cheama reincarcaTrimiteri() — fara nudge manual (US-008)."""
_seed_submission("sent") _seed_submission("sent")
r = client.get("/?tab=acasa") r = client.get("/?tab=acasa")
html = r.text html = r.text
@@ -141,6 +142,6 @@ def test_trimiteri_fara_poll_periodic_pe_tabel(client):
assert "every" not in wrap, f"tabelul nu trebuie sa aiba poll periodic: {wrap}" assert "every" not in wrap, f"tabelul nu trebuie sa aiba poll periodic: {wrap}"
assert "reincarcaTrimiteri" in wrap assert "reincarcaTrimiteri" in wrap
assert "trimiteriChanged" in wrap assert "trimiteriChanged" in wrap
# Mecanismul de nudge exista (banner + endpoint versiune). # Pollerul de versiune exista (auto-refresh la date noi externe). Nudge eliminat (US-008).
assert 'id="nudge-trimiteri"' in html
assert "/_fragments/trimiteri-versiune" in html assert "/_fragments/trimiteri-versiune" in html
assert 'id="nudge-trimiteri"' not in html

View File

@@ -1,11 +1,8 @@
"""Teste US-007 (PRD 3.6): bifa "auto-send" devine un comutator cu doua stari, """Teste US-007 (PRD 3.6) actualizate dupa US-002 (PRD 5.11).
etichetat pe COADA (nu pe trimitere).
Framing decis la poarta autoplan (UC-A): "Pune automat in coada" / "Tine pentru US-002: macro autosend_toggle neutralizat (intoarce string gol).
verificare". NU "Automat/Manual" (risc de send-safety peste declaratii ireversibile). Checkbox-ul name=auto_send a fost scos din UI. Coloanele DB raman.
`name="auto_send"` pastrat cu semantica de prezenta (checkbox value="true"): Testele de UI verifica ABSENTA toggle-ului; testele de backend (stocare DB) raman.
bifat -> auto_send True, nebifat -> absent -> False. Zero atingere backend, identic
cu ambele parsere existente (`Form(bool)` la /mapari si `bool(form.get())` la preview).
""" """
from __future__ import annotations from __future__ import annotations
@@ -94,38 +91,34 @@ def client(monkeypatch):
def _macro_html(checked: bool = True, form_id: str = "") -> str: def _macro_html(checked: bool = True, form_id: str = "") -> str:
"""Randeaza direct macro-ul comutatorului, izolat de restul paginii.""" """Randeaza direct macro-ul, izolat de restul paginii."""
from app.web.routes import templates from app.web.routes import templates
mod = templates.env.get_template("_macros.html").make_module({}) mod = templates.env.get_template("_macros.html").make_module({})
return str(mod.autosend_toggle(form_id=form_id, checked=checked)) return str(mod.autosend_toggle(form_id=form_id, checked=checked))
# --- markup / copy --- # --- markup: macro neutralizat dupa US-002 ---
def test_comutator_coada_prezent(): def test_comutator_coada_prezent():
"""5.5 (supersede framing 3.6): comutator etichetat Auto/Manual, compact. """US-002: macro autosend_toggle neutralizat -> output gol (fara checkbox)."""
Send-safety pastrata prin tooltip/Ajutor (Manual = tine pentru verificare; nimic nu
pleaca la RAR pana confirmi). Semantica de prezenta name=auto_send nealterata."""
html = _macro_html() html = _macro_html()
assert "name=\"auto_send\"" in html and 'value="true"' in html assert 'name="auto_send"' not in html, "US-002: checkbox auto_send scos din UI"
assert "Auto" in html and "Manual" in html, "ambele stari etichetate" assert html.strip() == "", f"macro neutralizat trebuie sa intoarca string gol, got: {html!r}"
assert "verificare" in html, "sensul de verificare manuala trebuie pastrat (tooltip/ajutor)"
assert "trimite" not in html.lower(), "fara cuvantul 'trimite' izolat in eticheta"
assert "auto-send" not in html, "jargonul 'auto-send' trebuie inlocuit"
def test_eticheta_scoped_pe_operatie(): def test_eticheta_scoped_pe_operatie():
"""Microcopy scoped pe operatie (NU global).""" """US-002: macro neutralizat -> nicio eticheta scoped."""
html = _macro_html() html = _macro_html()
assert "aceasta operatie" in html assert "aceasta operatie" not in html
assert html.strip() == ""
def test_default_pune_automat(): def test_default_pune_automat():
"""Default = "Pune automat in coada" (mirror la checkbox-ul `checked` de azi).""" """US-002: macro neutralizat intoarce gol indiferent de parametrul checked."""
html_default = _macro_html(checked=True) html_default = _macro_html(checked=True)
assert "checked" in html_default assert html_default.strip() == ""
html_off = _macro_html(checked=False) html_off = _macro_html(checked=False)
assert "checked" not in html_off, "starea stocata False nu trebuie bifata (H4)" assert html_off.strip() == ""
# --- comportament (zero atingere backend) --- # --- comportament (zero atingere backend) ---
@@ -161,7 +154,7 @@ def test_tine_pentru_verificare_mapeaza_auto_send_false(client):
# --- prezent in AMBELE locuri (mapari tab + panou preview) --- # --- prezent in AMBELE locuri (mapari tab + panou preview) ---
def test_comutator_in_tab_mapari(client): def test_comutator_in_tab_mapari(client):
"""Tabul Mapari (de-rezolvat) foloseste comutatorul de coada, nu jargonul vechi.""" """US-002: tabul Mapari nu mai contine checkbox auto_send (macro neutralizat)."""
from app.db import get_connection from app.db import get_connection
import json import json
acct = _create_account_user("tm@test.com") acct = _create_account_user("tm@test.com")
@@ -179,13 +172,12 @@ def test_comutator_in_tab_mapari(client):
_login(client, "tm@test.com") _login(client, "tm@test.com")
resp = client.get("/?tab=mapari") resp = client.get("/?tab=mapari")
assert resp.status_code == 200 assert resp.status_code == 200
# 5.5: comutatorul compact Auto/Manual e prezent in tabul Mapari assert 'name="auto_send"' not in resp.text, "US-002: checkbox auto_send scos din UI"
assert 'name="auto_send"' in resp.text assert "In coada" not in resp.text, "US-002: coloana 'In coada' scoasa"
assert "Manual" in resp.text and "Auto" in resp.text
def test_comutator_in_panou_preview(client): def test_comutator_in_panou_preview(client):
"""Panoul de mapare din preview are si el comutatorul + caption (azi lipsea caption).""" """US-002: panoul de mapare din preview nu mai contine checkbox auto_send."""
_create_account_user("pp@test.com") _create_account_user("pp@test.com")
_seed_nomenclator("R-FRANE") _seed_nomenclator("R-FRANE")
_login(client, "pp@test.com") _login(client, "pp@test.com")
@@ -211,6 +203,5 @@ def test_comutator_in_panou_preview(client):
}) })
assert r.status_code == 200 assert r.status_code == 200
assert "OP-NEMAPAT" in r.text, "operatia nemapata trebuie sa apara in panoul de mapare" assert "OP-NEMAPAT" in r.text, "operatia nemapata trebuie sa apara in panoul de mapare"
# 5.5: comutatorul compact Auto/Manual e prezent si in panoul de mapare din preview assert 'name="auto_send"' not in r.text, "US-002: checkbox auto_send scos din preview"
assert 'name="auto_send"' in r.text assert "In coada automat" not in r.text, "US-002: eticheta 'In coada automat' scoasa"
assert "Manual" in r.text and "Auto" in r.text

250
tests/test_import_commit.py Normal file
View File

@@ -0,0 +1,250 @@
"""Teste US-007 (PRD 5.11) — Post-commit: lista Trimiteri apare + refresh auto.
TDD RED: testele sunt scrise inainte de implementare.
Verifica:
1. Raspunsul confirma emite header HX-Trigger: trimiteriChanged.
2. Raspunsul confirma include OOB swap al #trimiteri-section cu submissions-wrap.
3. Prima vizita (first-run, zero trimiteri) randeaza placeholder #trimiteri-section in DOM.
4. Mesajul de succes este onest: contine numarul de prezentari puse in coada.
"""
from __future__ import annotations
import csv
import io
import os
import re
import tempfile
import pytest
from fastapi.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixture client cu DB izolat #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "us007.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()
# --------------------------------------------------------------------------- #
# Utilitare #
# --------------------------------------------------------------------------- #
def _csv_bytes(rows: list[dict], sep: str = ";") -> bytes:
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")
def _seed_nomenclator(client: TestClient, cod_prestatie: str = "R-FRANE", cod_op: str = "OP-FRANE") -> None:
"""Semeaza nomenclatorul si o mapare operatie->cod RAR (pentru randuri ok)."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?,?)",
(cod_prestatie, "Reparatie frane"),
)
conn.execute(
"INSERT OR IGNORE INTO operations_mapping "
"(account_id, cod_op_service, cod_prestatie, auto_send) VALUES (1,?,?,1)",
(cod_op, cod_prestatie),
)
conn.commit()
finally:
conn.close()
def _upload_preview_si_commit(client: TestClient, rows: list[dict]) -> tuple[int, object]:
"""Parcurge fluxul web complet: upload -> mapare coloane -> confirma.
Intoarce (import_id, raspuns_confirma).
Presupune: nomenclatorul si maparea operatiei sunt deja semanate.
"""
data = _csv_bytes(rows)
r = client.post(
"/_import/upload",
files={"file": ("test.csv", io.BytesIO(data), "text/csv")},
)
assert r.status_code == 200, r.text
m = re.search(r"/_import/(\d+)/", r.text)
assert m, f"import_id negasit in raspunsul de upload: {r.text[:400]}"
iid = int(m.group(1))
if f"/_import/{iid}/mapare-coloane" in r.text:
r2 = client.post(
f"/_import/{iid}/mapare-coloane",
data={
"colname": ["VIN", "Nr", "Data", "KM", "Operatie"],
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
"format_data": "YYYY-MM-DD",
},
)
assert r2.status_code == 200, r2.text
# GET preview pentru a afla n_ok — citit din atributul value al inputului #n-confirmat.
# (Regex generic pe "ok" ar putea prinde valori din CSS like min-height:36px -> 36.)
rp = client.get(f"/_import/{iid}/preview")
assert rp.status_code == 200, rp.text
m_ok = re.search(r'id="n-confirmat"[^>]*?value="(\d+)"', rp.text)
n_ok = int(m_ok.group(1)) if m_ok else len(rows)
r_conf = client.post(
f"/_import/{iid}/confirma",
data={
"csrf_token": "",
"n_confirmat": str(n_ok),
"confirmed_by": "test@us007.ro",
},
)
return iid, r_conf
# Date fixture: un singur rand ok
_ROWS_OK = [
{
"VIN": "WVWZZZ1KZAW007001",
"Nr": "B007TST",
"Data": "2026-06-15",
"KM": "77000",
"Operatie": "OP-FRANE",
},
]
# --------------------------------------------------------------------------- #
# Teste RED → GREEN #
# --------------------------------------------------------------------------- #
def test_confirma_emite_hx_trigger(client):
"""Raspunsul confirma include header HX-Trigger: trimiteriChanged.
Necesar pentru ca HTMX sa emita evenimentul pe <body>, pe care alte
elemente abonate (submissions-wrap) sa il prinda si sa se reimprospateze.
"""
_seed_nomenclator(client)
_, r = _upload_preview_si_commit(client, _ROWS_OK)
assert r.status_code == 200, r.text
hx_trigger = r.headers.get("HX-Trigger", "")
assert "trimiteriChanged" in hx_trigger, (
f"Header HX-Trigger lipseste sau nu contine 'trimiteriChanged'. "
f"Primit: {hx_trigger!r}"
)
def test_confirma_oob_trimiteri_section(client):
"""Raspunsul confirma include OOB swap al #trimiteri-section.
Elementul <section id='trimiteri-section'> apare in raspuns cu atributul
hx-swap-oob, astfel incat HTMX sa il injecteze in DOM fara reload complet.
La first-run, #trimiteri-section era absent din DOM (zero trimiteri anterior);
OOB-ul il populeaza si-l face vizibil.
"""
_seed_nomenclator(client)
_, r = _upload_preview_si_commit(client, _ROWS_OK)
assert r.status_code == 200, r.text
html = r.text
assert "hx-swap-oob" in html, (
"Atributul hx-swap-oob lipseste din raspuns — OOB swap nu e emis"
)
assert 'id="trimiteri-section"' in html or "id='trimiteri-section'" in html, (
"#trimiteri-section lipseste din raspuns OOB"
)
assert "submissions-wrap" in html, (
"#submissions-wrap lipseste din OOB — lista Trimiteri nu va aparea"
)
def test_confirma_mesaj_succes_onest(client):
"""Mesajul de succes mentioneaza numarul de prezentari puse in coada.
'Onest' inseamna ca mesajul reflecta exact numarul de randuri enqueue-uite,
nu o formulare vaga (ex. 'succes'). Permite utilizatorului sa verifice.
"""
_seed_nomenclator(client)
_, r = _upload_preview_si_commit(client, _ROWS_OK)
assert r.status_code == 200, r.text
html = r.text
# Mesajul trebuie sa contina cel putin numarul '1' (un rand ok in fixture)
# si sa indice ca randurile sunt "in coada" sau "prezentari".
assert re.search(r"\b1\b", html), "Numarul de prezentari (1) lipseste din raspuns"
# Cel putin unul din cuvintele cheie care indica succes de incarcare in coada
assert any(kw in html.lower() for kw in ("coada", "prezenta", "trimiter")), (
"Mesajul de succes nu contine cuvinte cheie despre enqueue (coada/prezentari/trimiteri)"
)
def test_commit_actualizeaza_status_bar(client):
"""Raspunsul confirma include OOB swap al #status-bar cu trigger trimiteriChanged.
Verifica doua lucruri:
1. #status-bar apare in raspunsul confirma cu hx-swap-oob (actualizare imediata
a contorului 'In asteptare' fara a astepta poll-ul de 15s).
2. Markup-ul #status-bar contine 'trimiteriChanged' in hx-trigger, deci la
urmatoarele evenimente trimiteriChanged (mapeaza, corectie etc.) bara se
re-incarca imediat, nu abia la 15s.
"""
_seed_nomenclator(client)
_, r = _upload_preview_si_commit(client, _ROWS_OK)
assert r.status_code == 200, r.text
html = r.text
assert 'id="status-bar"' in html or "id='status-bar'" in html, (
"#status-bar lipseste din raspuns — OOB swap nu va actualiza contoarele"
)
assert "hx-swap-oob" in html, (
"Atributul hx-swap-oob lipseste — cel putin un OOB swap trebuie emis"
)
assert "trimiteriChanged" in html, (
"trimiteriChanged lipseste din raspuns — #status-bar nu va reactiona "
"la eveniment si se va actualiza abia la urmatorul poll de 15s"
)
# Verifica direct ca fragmentul /_fragments/status contine trigger-ul in markup.
r_status = client.get("/_fragments/status")
assert r_status.status_code == 200, r_status.text
assert "trimiteriChanged" in r_status.text, (
"/_fragments/status nu contine 'trimiteriChanged' in hx-trigger — "
"bara nu va reactiona la evenimentul emis de confirma"
)
def test_acasa_placeholder_trimiteri_first_run(client):
"""GET / (zero trimiteri) randeaza elementul #trimiteri-section in DOM.
La first-run (niciun submission anterior), #trimiteri-section trebuia sa
existe in HTML ca placeholder gol/ascuns, astfel incat OOB swap-ul de la
confirma sa aiba tinta valida. Fara placeholder, HTMX ignora silentios OOB-ul
si lista Trimiteri nu apare dupa commit.
"""
r = client.get("/")
assert r.status_code == 200, r.text
html = r.text
assert 'id="trimiteri-section"' in html or "id='trimiteri-section'" in html, (
"#trimiteri-section lipseste din DOM la first-run — "
"OOB swap-ul de la confirma nu va gasi tinta si lista nu va aparea"
)

View File

@@ -150,7 +150,7 @@ def test_mapari_de_rezolvat_in_tabel(client):
def test_mapari_salvate_in_tabel(client): def test_mapari_salvate_in_tabel(client):
"""Sectiunea "Operatii salvate" randata ca tabel; H4: auto-send reflecta valoarea STOCATA.""" """Sectiunea "Operatii salvate" randata ca tabel. US-002: fara coloana auto_send."""
acct = _create_account_user("salv@test.com") acct = _create_account_user("salv@test.com")
_seed_nomenclator("R-FRANE", "Reparatie frane") _seed_nomenclator("R-FRANE", "Reparatie frane")
_seed_nomenclator("R-MOTOR", "Reparatie motor") _seed_nomenclator("R-MOTOR", "Reparatie motor")
@@ -165,9 +165,9 @@ def test_mapari_salvate_in_tabel(client):
assert "tablewrap" in sec, "tabelul Operatii salvate trebuie sa foloseasca .tablewrap" assert "tablewrap" in sec, "tabelul Operatii salvate trebuie sa foloseasca .tablewrap"
assert "<table" in sec and "<th" in sec, "Operatii salvate trebuie randat ca tabel cu antet" assert "<table" in sec and "<th" in sec, "Operatii salvate trebuie randat ca tabel cu antet"
assert "OP-AUTO" in sec and "OP-MANUAL" in sec assert "OP-AUTO" in sec and "OP-MANUAL" in sec
assert 'name="auto_send"' in sec # US-002: checkbox auto_send si coloana In coada scoase din UI
# H4: exact maparile cu auto_send STOCAT True sunt bifate (aici: o singura) assert 'name="auto_send"' not in sec, "US-002: checkbox auto_send scos din tabelul salvate"
assert sec.count("checked") == 1, "comutatorul auto-send trebuie sa reflecte valoarea stocata, nu un default" assert "In coada" not in sec, "US-002: coloana In coada scoasa din tabelul salvate"
# POST-urile neschimbate # POST-urile neschimbate
assert 'hx-post="/mapari/salvate"' in sec assert 'hx-post="/mapari/salvate"' in sec
assert 'hx-post="/mapari/salvate/sterge"' in sec assert 'hx-post="/mapari/salvate/sterge"' in sec

View File

@@ -380,9 +380,12 @@ def test_unmapped_are_3niveluri(client):
assert err["fix"] assert err["fix"]
def test_auto_send_oprit_3niveluri(client): def test_auto_send_oprit_3niveluri_noul_comportament(client):
"""Mapare cu auto_send=0 -> needs_mapping; rar_error are cheie 'auto_send' """Mapare cu auto_send=0 -> queued (auto_send ignorat dupa US-001).
PASTRATA + campurile AUTO_SEND_OPRIT (cod/problema/cauza/fix)."""
Dupa US-001: classify_prezentare nu mai produce ramura AUTO_SEND_OPRIT.
O operatie cu cod rezolvat (indiferent de auto_send) -> queued direct.
"""
import json import json
from app.mapping import classify_prezentare from app.mapping import classify_prezentare
@@ -396,15 +399,9 @@ def test_auto_send_oprit_3niveluri(client):
mapping = {"OP_REVIEW": "OE-1"} mapping = {"OP_REVIEW": "OE-1"}
mapping_meta = {"OP_REVIEW": {"cod_prestatie": "OE-1", "auto_send": False}} mapping_meta = {"OP_REVIEW": {"cod_prestatie": "OE-1", "auto_send": False}}
res = classify_prezentare(content, mapping, mapping_meta) res = classify_prezentare(content, mapping, mapping_meta)
assert res["status"] == "needs_mapping" assert res["status"] == "queued", (
err = json.loads(res["rar_error"]) f"dupa US-001 auto_send=0 -> queued (nu needs_mapping), got {res['status']}"
# Cheia originala pastrata )
assert "auto_send" in err
# 3 niveluri prezente
assert err["cod"] == "AUTO_SEND_OPRIT"
assert err["problema"]
assert err["cauza"]
assert err["fix"]
def test_needs_data_pass_through(client): def test_needs_data_pass_through(client):
@@ -431,3 +428,153 @@ def test_needs_data_pass_through(client):
assert "cod" in e, f"lipseste 'cod' in {e}" assert "cod" in e, f"lipseste 'cod' in {e}"
assert "problema" in e, f"lipseste 'problema' in {e}" assert "problema" in e, f"lipseste 'problema' in {e}"
assert "fix" in e, f"lipseste 'fix' in {e}" assert "fix" in e, f"lipseste 'fix' in {e}"
# =========================================================================== #
# US-001: Scoate hold auto_send din mapare — teste RED (inainte de implementare)
# =========================================================================== #
@pytest.fixture()
def db_conn(monkeypatch, tmp_path):
"""Conexiune directa la o DB temporara, fara client HTTP."""
db_path = str(tmp_path / "us001.db")
monkeypatch.setenv("AUTOPASS_DB_PATH", db_path)
from app.config import get_settings
get_settings.cache_clear()
from app.db import init_db
init_db()
from app.db import get_connection
c = get_connection()
yield c
c.close()
get_settings.cache_clear()
def test_operatie_mapata_intra_in_queued_indiferent_de_autosend():
"""classify_prezentare cu mapare auto_send=0 -> queued (nu needs_mapping).
Dupa US-001: has_no_auto_send nu mai blocheaza; un cod rezolvat e direct queued.
"""
from app.mapping import classify_prezentare
content = {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [{"cod_op_service": "OP_REVIEW", "denumire": "Operatie cu review"}],
}
mapping = {"OP_REVIEW": "OE-1"}
mapping_meta = {"OP_REVIEW": {"cod_prestatie": "OE-1", "auto_send": False}}
valid_codes = {"OE-1"}
result = classify_prezentare(content, mapping, mapping_meta, valid_codes)
assert result["status"] == "queued", (
f"asteptat queued (auto_send ignorat), got {result['status']}: {result.get('rar_error')}"
)
def test_regula_text_rezolvata_nu_mai_tine_randul():
"""Regula text cu auto_send=0 rezolva codul -> queued (nu needs_mapping held).
Dupa US-001: regula_fara_autosend nu se mai seteaza; codul rezolvat = queued direct.
"""
from app.mapping import classify_prezentare
content = {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [{"cod_op_service": "X99", "denumire": "Verificare faruri"}],
}
text_rules = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}]
valid_codes = {"OE-2"}
result = classify_prezentare(content, {}, {}, valid_codes, text_rules)
assert result["status"] == "queued", (
f"asteptat queued (regula text, auto_send ignorat), got {result['status']}: {result.get('rar_error')}"
)
def test_fara_stare_needs_mapping_pe_auto_send_oprit():
"""has_no_auto_send intotdeauna False dupa US-001; nu mai produce AUTO_SEND_OPRIT."""
from app.mapping import has_no_auto_send
mapping_meta_false = {"OP_REVIEW": {"cod_prestatie": "OE-1", "auto_send": False}}
resolved = [{"cod_op_service": "OP_REVIEW", "cod_prestatie": "OE-1"}]
assert has_no_auto_send(resolved, mapping_meta_false) is False, (
"has_no_auto_send trebuie sa intoarca mereu False dupa US-001"
)
resolved_cu_flag = [{"cod_op_service": "X", "cod_prestatie": "OE-1", "regula_fara_autosend": True}]
assert has_no_auto_send(resolved_cu_flag, {}) is False, (
"has_no_auto_send ignora regula_fara_autosend dupa US-001"
)
def test_niciun_rand_existent_nu_se_dezgheata(db_conn):
"""Randuri legacy needs_mapping-din-auto_send: fara afordanta UI (cod prezent),
dezghetabile via reresolve_account explicit (nu automat).
_nemapate_pentru_submission -> [] (fara panou mapare in UI).
reresolve_account cu mapare activa -> requeued=1 (dezghet via actiune explicita).
"""
import json as _json
payload = {
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B1",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [{"cod_op_service": "X1", "cod_prestatie": "OE-1"}],
}
rar_error = _json.dumps({"auto_send": "cod mapat cu auto_send=0; review manual"})
db_conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error) "
"VALUES (?, ?, ?, ?, ?)",
("k-legacy-us001", 1, "needs_mapping", _json.dumps(payload), rar_error),
)
db_conn.execute(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES ('OE-1', 'Test')"
)
db_conn.execute(
"INSERT OR REPLACE INTO operations_mapping "
"(account_id, cod_op_service, cod_prestatie, auto_send) VALUES (1, 'X1', 'OE-1', 0)"
)
db_conn.commit()
row = db_conn.execute(
"SELECT * FROM submissions WHERE idempotency_key='k-legacy-us001'"
).fetchone()
nomenclator = [{"cod_prestatie": "OE-1", "nume_prestatie": "Test"}]
# Nicio afordanta UI (cod deja prezent -> nu se arata panoul de mapare)
from app.web.routes import _nemapate_pentru_submission
assert _nemapate_pentru_submission(row, nomenclator) == [], (
"_nemapate_pentru_submission trebuie sa intoarca [] (cod deja prezent)"
)
# Dezghetare via reresolve_account explicit (actiune admin la deploy)
from app.mapping import reresolve_account
stats = reresolve_account(db_conn, 1)
assert stats["requeued"] == 1, (
f"reresolve_account trebuie sa requeueze randul legacy: {stats}"
)
row2 = db_conn.execute(
"SELECT status FROM submissions WHERE idempotency_key='k-legacy-us001'"
).fetchone()
assert row2["status"] == "queued"
def test_canal_api_auto_send_ignorat_intra_queued():
"""classify_prezentare (canal API) cu mapping_meta auto_send=0 -> queued.
Campul auto_send din mapping_meta nu mai afecteaza decizia de clasificare.
"""
from app.mapping import classify_prezentare
content = {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [{"cod_op_service": "ITP-CHECK", "denumire": "Inspectie"}],
}
mapping = {"ITP-CHECK": "OE-1"}
mapping_meta = {"ITP-CHECK": {"cod_prestatie": "OE-1", "auto_send": False}}
valid_codes = {"OE-1"}
result = classify_prezentare(content, mapping, mapping_meta, valid_codes)
assert result["status"] == "queued", (
f"canal API: auto_send ignorat -> asteptat queued, got {result['status']}"
)

View File

@@ -0,0 +1,283 @@
"""Teste US-003 (PRD 5.11) — Preview pas 3 in format identic cu tabelul Trimiteri.
Proces TDD: aceste teste sunt scrise INAINTE de implementare (RED) si verifica:
1. Coloana Note nu afiseaza repr Python brut (lista/dict Python) pentru needs_mapping.
2. Starea afisata in pill-ul randului este eticheta umana (nu codul brut).
3. Tabelul de preview foloseste clasa CSS .tabel-trimiteri.
Fixture real cu rand needs_mapping OBLIGATORIU pentru test_preview_nu_contine_repr_python
(altfel testul trece in gol pe un preview fara erori in Note).
"""
from __future__ import annotations
import csv
import io
import os
import re
import tempfile
import pytest
from fastapi.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixture client cu DB izolat #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "preview_us003.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()
# --------------------------------------------------------------------------- #
# Utilitare #
# --------------------------------------------------------------------------- #
def _csv_bytes(rows: list[dict], sep: str = ";") -> bytes:
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")
def _seed_nomenclator_si_mapare(
cod_prestatie: str = "R-FRANE", cod_op: str = "OP-FRANE"
) -> None:
"""Semeaza nomenclatorul si o mapare pentru a produce randuri ok in preview."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?,?)",
(cod_prestatie, "Reparatie frane"),
)
conn.execute(
"INSERT OR IGNORE INTO operations_mapping "
"(account_id, cod_op_service, cod_prestatie, auto_send) VALUES (1,?,?,1)",
(cod_op, cod_prestatie),
)
conn.commit()
finally:
conn.close()
def _upload_and_preview(client: TestClient, rows: list[dict]) -> tuple[int, str]:
"""Upload CSV + salveaza mapare coloane (daca lipseste) + GET preview.
Returneaza (import_id, html_preview).
"""
data = _csv_bytes(rows)
r = client.post(
"/_import/upload",
files={"file": ("test.csv", io.BytesIO(data), "text/csv")},
)
assert r.status_code == 200, r.text
# Extrage import_id din URL-urile prezente in raspuns
m = re.search(r"/_import/(\d+)/", r.text)
assert m, f"import_id negasit in raspunsul de upload: {r.text[:400]}"
iid = int(m.group(1))
# Daca s-a returnat formularul de mapare coloane, il salvam
if f"/_import/{iid}/mapare-coloane" in r.text:
r2 = client.post(
f"/_import/{iid}/mapare-coloane",
data={
"colname": ["VIN", "Nr", "Data", "KM", "Operatie"],
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
"format_data": "YYYY-MM-DD",
},
)
assert r2.status_code == 200, r2.text
r3 = client.get(f"/_import/{iid}/preview")
assert r3.status_code == 200, r3.text
return iid, r3.text
# --------------------------------------------------------------------------- #
# Date fixture #
# --------------------------------------------------------------------------- #
# Un rand cu operatie nemapata — produce starea needs_mapping cu errors=[{"unmapped":[...]}]
_ROWS_UNMAPPED = [
{
"VIN": "WVWZZZ1KZAW000001",
"Nr": "B001TST",
"Data": "2026-06-15",
"KM": "123456",
"Operatie": "OP-FARA-COD",
},
]
# Un rand ok (mapare existenta) + un rand unmapped
_ROWS_OK_SI_UNMAPPED = [
{
"VIN": "WVWZZZ1KZAW000001",
"Nr": "B001TST",
"Data": "2026-06-15",
"KM": "123456",
"Operatie": "OP-FRANE",
},
{
"VIN": "WVWZZZ1KZAW000002",
"Nr": "B002TST",
"Data": "2026-06-15",
"KM": "100000",
"Operatie": "OP-FARA-COD",
},
]
# Doua randuri identice — produc starea duplicate_in_file
_ROWS_DUPLICATE = [
{
"VIN": "WVWZZZ1KZAW000001",
"Nr": "B001TST",
"Data": "2026-06-15",
"KM": "123456",
"Operatie": "OP-FRANE",
},
{
"VIN": "WVWZZZ1KZAW000001",
"Nr": "B001TST",
"Data": "2026-06-15",
"KM": "123456",
"Operatie": "OP-FRANE",
},
]
# --------------------------------------------------------------------------- #
# Teste RED → GREEN #
# --------------------------------------------------------------------------- #
def test_preview_nu_contine_repr_python(client):
"""Coloana Note nu afiseaza repr Python brut pentru randuri needs_mapping.
Fixture real cu rand needs_mapping OBLIGATORIU — altfel testul ar trece in gol
pe un preview fara erori (coloana Note goala nu produce repr).
Repr Python apare in codul curent cand Jinja2 randeaza
``{{ e.values() | list | first }}`` unde valoarea e lista ``unmapped``:
[{'cod_op_service': 'OP-FARA-COD', 'denumire': 'OP-FARA-COD'}]
"""
_, html = _upload_and_preview(client, _ROWS_UNMAPPED)
# Confirma ca preview-ul contine cel putin un rand needs_mapping (fixture corect)
has_nm = (
"s-needs_mapping" in html
or "needs_mapping" in html
or "Cod RAR lipsa" in html
)
assert has_nm, "Testul necesita cel putin un rand needs_mapping — fixture gresit"
# Repr Python brut: Jinja2 auto-escapa ghilimelele simple -> &#39;
# Deci [{'cod_op_service': ...}] devine [{&#39;cod_op_service&#39;: ...}] in HTML.
# Verificam secventa specifica a repr-ului HTML-escapata:
assert "&#39;cod_op_service&#39;" not in html, (
"Repr Python HTML-escapata (&#39;cod_op_service&#39;) gasita in HTML — "
"adaptorul trebuie sa formateze erorile uman INAINTE de randare in Note"
)
def test_preview_stare_eticheta_umana(client):
"""Starea din pill-ul fiecarui rand este eticheta umana, nu codul brut.
Testeaza starile ok si needs_mapping (cele mai comune in preview).
Celelalte stari (needs_review, already_sent, duplicate_in_file) sunt in testele ext.
"""
_seed_nomenclator_si_mapare()
_, html = _upload_and_preview(client, _ROWS_OK_SI_UNMAPPED)
# Etichetele umane trebuie sa apara in HTML (din pill-urile randurilor)
# "Gata de trimis" cu majuscula — nu confundam cu "gata de trimis" din
# summary pills (deja prezente in codul curent cu lowercase)
assert "Gata de trimis" in html, (
"Eticheta umana 'Gata de trimis' lipsa — "
"pill-ul randului ok afiseaza inca codul brut"
)
assert "Cod RAR lipsa" in html, (
"Eticheta umana 'Cod RAR lipsa' lipsa — "
"pill-ul randului needs_mapping afiseaza inca codul brut"
)
# Codurile brute NU trebuie sa apara ca text vizibil al pill-ului de rand.
# Cautam <span class="pill ...">ok</span> (codul brut ca text al pill-ului).
assert re.search(r'class="pill[^"]*">ok<', html) is None, (
"Pill cu text brut 'ok' gasit in randuri — trebuie 'Gata de trimis'"
)
assert re.search(r'class="pill[^"]*">needs_mapping<', html) is None, (
"Pill cu text brut 'needs_mapping' gasit in randuri — trebuie 'Cod RAR lipsa'"
)
def test_preview_stare_eticheta_umana_duplicate(client):
"""Starea duplicate_in_file afiseaza eticheta umana 'Duplicat in fisier'."""
_seed_nomenclator_si_mapare()
_, html = _upload_and_preview(client, _ROWS_DUPLICATE)
# Confirma ca exista randuri duplicate_in_file
has_dup = "duplicate_in_file" in html or "Duplicat in fisier" in html
assert has_dup, "Testul necesita randuri duplicate_in_file — fixture gresit"
assert "Duplicat in fisier" in html, (
"Eticheta umana 'Duplicat in fisier' lipsa — "
"pill-ul randului duplicate_in_file afiseaza inca codul brut"
)
assert re.search(r'class="pill[^"]*">duplicate_in_file<', html) is None, (
"Pill cu text brut 'duplicate_in_file' gasit — trebuie 'Duplicat in fisier'"
)
def test_nota_umana_preview_needs_mapping_cu_flag_prioritizeaza_unmapped():
"""needs_mapping + flag -> Note afiseaza 'Cod RAR lipsa', nu textul flag-ului.
BUG 3: nota_umana_preview verifica `if flags:` inaintea ramurei unmapped.
Un rand needs_mapping care are si un flag (ex. VIN numeric) afisa textul
flag-ului in loc de 'Cod RAR lipsa pentru: COD' — exact confuzia US-003
voia s-o evite (userul corecteaza data si ramane blocat pe cod).
Fix: cand status == 'needs_mapping', prioritizeaza ramura unmapped.
"""
from app.web.labels import nota_umana_preview
errors = [{"unmapped": [{"cod_op_service": "OP-TEST", "denumire": "Op Test"}]}]
flags = ["VIN numeric (12345) — verificati seria sasiului"]
result = nota_umana_preview("needs_mapping", errors, flags)
assert "Cod RAR lipsa" in result, (
f"needs_mapping cu flag trebuie sa afiseze 'Cod RAR lipsa', "
f"nu textul flag-ului — primit: {result!r}"
)
assert "VIN numeric" not in result, (
f"needs_mapping cu flag NU trebuie sa afiseze textul flag-ului — primit: {result!r}"
)
def test_preview_foloseste_clasa_tabel_trimiteri(client):
"""Tabelul de preview foloseste clasa CSS .tabel-trimiteri (nu doar .tablewrap).
Necesara pentru:
- table-layout:fixed (fara overflow orizontal la 1280px)
- carduri <768px: td::before citeste data-eticheta
- col-* latimi pentru cele 4 coloane extra (col-km, col-note, col-verificat, col-actiuni)
"""
_, html = _upload_and_preview(client, _ROWS_UNMAPPED)
assert "tabel-trimiteri" in html, (
"Clasa CSS 'tabel-trimiteri' lipsa din tabelul de preview — "
"necesara pentru table-layout:fixed si carduri mobile"
)

View File

@@ -1,8 +1,18 @@
"""Teste T6: gate auto_send pe coduri nou-mapate (OV-1). """Teste T6: comportament dupa US-001 (PRD 5.11) — auto_send nu mai tine randuri.
Verify: US-001: has_no_auto_send neutralizat (return False); un cod rezolvat (mapare exacta
(a) cod nou-mapat cu auto_send=0 -> nu auto-send, review manual. sau regula text) -> queued direct, indiferent de auto_send=0/1 in mapping_meta.
(b) REGRESIE: mapare existenta cu auto_send=1 tot se requeue ca azi.
Coloanele DB operations_mapping.auto_send si operation_text_rules.auto_send RAMAN
(default 1, ne-citite pentru hold). Functia has_no_auto_send RAMANE DEFINITA (importata
in routes.py + import_router.py) dar intoarce mereu False.
Inainte de US-001:
(a) cod nou-mapat cu auto_send=0 -> nu auto-send, review manual (needs_mapping).
(b) mapare existenta cu auto_send=1 -> queued.
Dupa US-001:
(a) cod nou-mapat cu auto_send=0 -> queued (ca si (b)).
(b) mapare existenta cu auto_send=1 -> queued (neschimbat).
""" """
from __future__ import annotations from __future__ import annotations
@@ -70,6 +80,7 @@ def _add_mapping(conn, account_id=1, cod_op="ITP-CHECK", cod_prestatie="OE-1", a
# --- load_mapping_meta --- # --- load_mapping_meta ---
def test_load_mapping_meta_returns_auto_send(conn): def test_load_mapping_meta_returns_auto_send(conn):
"""load_mapping_meta expune coloana auto_send din DB (ramane, default 1)."""
from app.mapping import load_mapping_meta from app.mapping import load_mapping_meta
_add_mapping(conn, cod_op="ITP-1", cod_prestatie="OE-1", auto_send=True) _add_mapping(conn, cod_op="ITP-1", cod_prestatie="OE-1", auto_send=True)
_add_mapping(conn, cod_op="ITP-2", cod_prestatie="OE-2", auto_send=False) _add_mapping(conn, cod_op="ITP-2", cod_prestatie="OE-2", auto_send=False)
@@ -79,18 +90,20 @@ def test_load_mapping_meta_returns_auto_send(conn):
assert meta["ITP-2"]["auto_send"] is False assert meta["ITP-2"]["auto_send"] is False
# --- has_no_auto_send --- # --- has_no_auto_send (neutralizat, mereu False) ---
def test_has_no_auto_send_detecteaza_false(conn): def test_has_no_auto_send_mereu_false_cu_auto_send_fals(conn):
"""has_no_auto_send intoarce False chiar si cand auto_send=False in mapping_meta (US-001)."""
from app.mapping import has_no_auto_send from app.mapping import has_no_auto_send
mapping_meta = { mapping_meta = {
"ITP-1": {"cod_prestatie": "OE-1", "auto_send": False}, "ITP-1": {"cod_prestatie": "OE-1", "auto_send": False},
} }
resolved = [{"cod_op_service": "ITP-1", "cod_prestatie": "OE-1"}] resolved = [{"cod_op_service": "ITP-1", "cod_prestatie": "OE-1"}]
assert has_no_auto_send(resolved, mapping_meta) is True assert has_no_auto_send(resolved, mapping_meta) is False
def test_has_no_auto_send_trece_cu_true(conn): def test_has_no_auto_send_mereu_false_cu_auto_send_true(conn):
"""has_no_auto_send intoarce False si cand auto_send=True (neschimbat, mereu False)."""
from app.mapping import has_no_auto_send from app.mapping import has_no_auto_send
mapping_meta = { mapping_meta = {
"ITP-1": {"cod_prestatie": "OE-1", "auto_send": True}, "ITP-1": {"cod_prestatie": "OE-1", "auto_send": True},
@@ -100,46 +113,44 @@ def test_has_no_auto_send_trece_cu_true(conn):
def test_has_no_auto_send_direct_cod_prestatie(conn): def test_has_no_auto_send_direct_cod_prestatie(conn):
"""Item cu cod_prestatie direct (fara cod_op_service) nu e afectat de auto_send.""" """Item cu cod_prestatie direct (fara cod_op_service) -> False (neschimbat)."""
from app.mapping import has_no_auto_send from app.mapping import has_no_auto_send
mapping_meta = {} mapping_meta = {}
resolved = [{"cod_prestatie": "OE-1"}] resolved = [{"cod_prestatie": "OE-1"}]
assert has_no_auto_send(resolved, mapping_meta) is False assert has_no_auto_send(resolved, mapping_meta) is False
# --- reresolve_account cu auto_send=0 --- # --- reresolve_account cu auto_send=0 (US-001: acum requeue) ---
def test_reresolve_auto_send_zero_nu_requeue(conn): def test_reresolve_auto_send_zero_acum_requeue(conn):
"""(a) cod nou-mapat cu auto_send=0 -> ramane needs_mapping (nu trece pe queued).""" """(US-001) cod nou-mapat cu auto_send=0 -> queued (nu mai review_manual)."""
from app.mapping import reresolve_account from app.mapping import reresolve_account
sid = _insert_needs_mapping(conn, cod_op="ITP-CHECK") sid = _insert_needs_mapping(conn, cod_op="ITP-CHECK")
_add_mapping(conn, cod_op="ITP-CHECK", cod_prestatie="OE-1", auto_send=False) _add_mapping(conn, cod_op="ITP-CHECK", cod_prestatie="OE-1", auto_send=False)
stats = reresolve_account(conn, 1) stats = reresolve_account(conn, 1)
assert stats["review_manual"] == 1 assert stats["requeued"] == 1, f"asteptat requeued=1, got {stats}"
assert stats["requeued"] == 0 assert stats.get("review_manual", 0) == 0
row = conn.execute("SELECT status, rar_error FROM submissions WHERE id=?", (sid,)).fetchone() row = conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone()
assert row["status"] == "needs_mapping" assert row["status"] == "queued"
err = json.loads(row["rar_error"])
assert "auto_send" in err
def test_reresolve_auto_send_unu_requeue(conn): def test_reresolve_auto_send_unu_requeue(conn):
"""(b) REGRESIE: mapare cu auto_send=1 tot se requeue ca azi.""" """REGRESIE: mapare cu auto_send=1 -> queued (neschimbat)."""
from app.mapping import reresolve_account from app.mapping import reresolve_account
sid = _insert_needs_mapping(conn, cod_op="ITP-CHECK") sid = _insert_needs_mapping(conn, cod_op="ITP-CHECK")
_add_mapping(conn, cod_op="ITP-CHECK", cod_prestatie="OE-1", auto_send=True) _add_mapping(conn, cod_op="ITP-CHECK", cod_prestatie="OE-1", auto_send=True)
stats = reresolve_account(conn, 1) stats = reresolve_account(conn, 1)
assert stats["requeued"] == 1 assert stats["requeued"] == 1
assert stats["review_manual"] == 0 assert stats.get("review_manual", 0) == 0
row = conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone() row = conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone()
assert row["status"] == "queued" assert row["status"] == "queued"
# --- POST /v1/prezentari cu auto_send=0 --- # --- POST /v1/prezentari cu auto_send=0 (US-001: acum queued) ---
def _body_with_op(cod_op="ITP-CHECK"): def _body_with_op(cod_op="ITP-CHECK"):
return { return {
@@ -154,8 +165,8 @@ def _body_with_op(cod_op="ITP-CHECK"):
} }
def test_post_auto_send_zero_nu_queued(client, env): def test_post_auto_send_zero_acum_queued(client, env):
"""(a) Via API: cod nou-mapat cu auto_send=0 -> nu 'queued', review manual.""" """(US-001) Via API: cod nou-mapat cu auto_send=0 -> queued (nu mai needs_mapping)."""
from app.db import get_connection from app.db import get_connection
conn2 = get_connection() conn2 = get_connection()
try: try:
@@ -166,12 +177,11 @@ def test_post_auto_send_zero_nu_queued(client, env):
r = client.post("/v1/prezentari", json=_body_with_op("ITP-X")) r = client.post("/v1/prezentari", json=_body_with_op("ITP-X"))
assert r.status_code == 200 assert r.status_code == 200
status = r.json()["results"][0]["status"] status = r.json()["results"][0]["status"]
assert status != "queued", f"auto_send=0 nu trebuie sa fie queued, e: {status}" assert status == "queued", f"auto_send=0 dupa US-001 -> queued, e: {status}"
assert status == "needs_mapping"
def test_post_auto_send_unu_queued(client, env): def test_post_auto_send_unu_queued(client, env):
"""(b) REGRESIE: mapare existenta cu auto_send=1 -> queued ca azi.""" """REGRESIE: mapare existenta cu auto_send=1 -> queued ca inainte."""
from app.db import get_connection from app.db import get_connection
conn2 = get_connection() conn2 = get_connection()
try: try:

View File

@@ -1,9 +1,10 @@
"""FIX (code-review 5.8): o regula text cu auto_send=0 (DEFAULT, decizia CEO) trebuie """Teste reguli text + auto_send dupa US-001 (PRD 5.11).
sa TINA randul pentru verificare umana (needs_mapping/review), NU sa-l trimita automat.
`has_no_auto_send` trebuie sa prinda si itemii rezolvati-prin-regula-text cu auto_send=0, Inainte de US-001: o regula text cu auto_send=0 tinea randul in needs_mapping
nu doar maparile exacte din operations_mapping. Adnotarile (cod_sursa/regula_fara_autosend) (regula_fara_autosend=True + has_no_auto_send -> True).
trebuie curatate la fiecare rezolvare (anti-staleness).
Dupa US-001: has_no_auto_send mereu False; o regula text cu auto_send=0 rezolva
codul RAR si randul intra in queued direct. regula_fara_autosend nu se mai seteaza.
Functii pure -> teste fara DB. Functii pure -> teste fara DB.
""" """
@@ -26,34 +27,37 @@ def _content_cu(op_denumire="Verificare faruri"):
return {**_CONTENT, "prestatii": [{"cod_op_service": "X99", "denumire": op_denumire}]} return {**_CONTENT, "prestatii": [{"cod_op_service": "X99", "denumire": op_denumire}]}
def test_regula_auto_send_0_tine_randul(): def test_regula_auto_send_0_acum_queued():
"""Regula text auto_send=0 + continut valid -> needs_mapping (review), NU queued.""" """Regula text auto_send=0 + continut valid -> queued (nu mai needs_mapping) dupa US-001."""
tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}] tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}]
cl = classify_prezentare(_content_cu(), {}, {}, VALID, tr) cl = classify_prezentare(_content_cu(), {}, {}, VALID, tr)
assert cl["status"] == "needs_mapping", f"asteptat needs_mapping (held), got {cl['status']}" assert cl["status"] == "queued", f"asteptat queued (auto_send ignorat), got {cl['status']}"
def test_regula_auto_send_1_trece_in_coada(): def test_regula_auto_send_1_trece_in_coada():
"""Regula text auto_send=1 + continut valid -> queued (trimite automat).""" """Regula text auto_send=1 + continut valid -> queued (neschimbat)."""
tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 1, "priority": 0}] tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 1, "priority": 0}]
cl = classify_prezentare(_content_cu(), {}, {}, VALID, tr) cl = classify_prezentare(_content_cu(), {}, {}, VALID, tr)
assert cl["status"] == "queued", f"asteptat queued, got {cl['status']}" assert cl["status"] == "queued", f"asteptat queued, got {cl['status']}"
def test_has_no_auto_send_prinde_flagul_regula(): def test_has_no_auto_send_mereu_false_cu_flag_regula():
"""has_no_auto_send=True cand un item poarta regula_fara_autosend; codul e tot rezolvat.""" """has_no_auto_send=False chiar daca regula_fara_autosend e prezent (US-001)."""
tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}] tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}]
resolved, unmapped = resolve_prestatii( resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "X99", "denumire": "Verificare faruri"}], {}, VALID, tr [{"cod_op_service": "X99", "denumire": "Verificare faruri"}], {}, VALID, tr
) )
assert unmapped == [] assert unmapped == []
assert resolved[0]["cod_prestatie"] == "OE-2" assert resolved[0]["cod_prestatie"] == "OE-2"
assert resolved[0].get("regula_fara_autosend") is True # Flagul regula_fara_autosend nu se mai seteaza (US-001)
assert has_no_auto_send(resolved, {}) is True assert resolved[0].get("regula_fara_autosend") is None, (
"regula_fara_autosend nu trebuie sa mai fie setat dupa US-001"
)
assert has_no_auto_send(resolved, {}) is False
def test_has_no_auto_send_fals_cand_regula_auto_send_1(): def test_has_no_auto_send_fals_cand_regula_auto_send_1():
"""Regula auto_send=1 -> fara flag -> has_no_auto_send False.""" """Regula auto_send=1 -> fara flag -> has_no_auto_send False (neschimbat)."""
tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 1, "priority": 0}] tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 1, "priority": 0}]
resolved, _ = resolve_prestatii( resolved, _ = resolve_prestatii(
[{"cod_op_service": "X99", "denumire": "Verificare faruri"}], {}, VALID, tr [{"cod_op_service": "X99", "denumire": "Verificare faruri"}], {}, VALID, tr
@@ -64,14 +68,13 @@ def test_has_no_auto_send_fals_cand_regula_auto_send_1():
def test_adnotari_stale_curatate_la_mapare_exacta(): def test_adnotari_stale_curatate_la_mapare_exacta():
"""Un item venit cu cod_sursa/regula_fara_autosend stale dar re-rezolvat acum prin """Un item venit cu cod_sursa/regula_fara_autosend stale dar re-rezolvat acum prin
mapare EXACTA cu auto_send=1 -> adnotarile sunt curatate; randul NU mai e tinut.""" mapare EXACTA -> adnotarile sunt curatate; randul NU mai e tinut (neschimbat)."""
item_stale = { item_stale = {
"cod_op_service": "X99", "cod_op_service": "X99",
"denumire": "Verificare faruri", "denumire": "Verificare faruri",
"cod_sursa": "text_rule:verificare", "cod_sursa": "text_rule:verificare",
"regula_fara_autosend": True, "regula_fara_autosend": True,
} }
# Acum X99 are mapare exacta cu auto_send=1.
mapping = {"X99": "OE-2"} mapping = {"X99": "OE-2"}
mapping_meta = {"X99": {"cod_prestatie": "OE-2", "auto_send": True}} mapping_meta = {"X99": {"cod_prestatie": "OE-2", "auto_send": True}}
resolved, _ = resolve_prestatii([item_stale], mapping, VALID, text_rules=None) resolved, _ = resolve_prestatii([item_stale], mapping, VALID, text_rules=None)

130
tests/test_web_acasa.py Normal file
View File

@@ -0,0 +1,130 @@
"""Teste US-006 (PRD 5.11): Import = container compact colapsabil.
TDD — testele sunt scrise INAINTE de implementare (RED), apoi devin GREEN.
Verifica:
- Containerul de import e colapsat implicit cand exista trimiteri (are_trimiteri=True)
- Containerul de import e deschis la first-run (are_trimiteri=False)
- Implementare cu <details> nativ (CSS-only disclosure)
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
# ============================================================
# Fixture
# ============================================================
@pytest.fixture()
def client(monkeypatch):
"""Client cu BD izolata; auth dezactivat (cont implicit id=1)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "acasa_test.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
from fastapi.testclient import TestClient
with TestClient(app) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
# ============================================================
# Helpere
# ============================================================
def _add_submission(acct_id: int = 1) -> None:
"""Adauga un submission minimal pentru cont (simuleaza un import efectuat)."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, 'queued', ?)",
(f"test_key_us006_{acct_id}", acct_id, json.dumps({"test": True})),
)
finally:
conn.close()
def _detalii_import(html: str) -> re.Match | None:
"""Returneaza match-ul pentru tagul <details> care contine containerul de import."""
# Cauta <details cu id="import-details" (sau variante)
m = re.search(r'<details([^>]*)id=["\']import-details["\']([^>]*)>', html)
if m:
return m
# Fallback: primul <details> care nu e .kebab (e containerul de import)
m2 = re.search(r'<details(?!.*class=["\'][^"\']*kebab)([^>]*)>', html)
return m2
# ============================================================
# test_import_colapsat_cand_are_trimiteri
# ============================================================
def test_import_colapsat_cand_are_trimiteri(client):
"""Cand contul are deja trimiteri, containerul de import e colapsat (fara atribut open).
Serverul NU seteaza atributul `open` pe <details> cand are_trimiteri=True,
deci browserul il randeaza colapsat implicit.
"""
_add_submission(acct_id=1)
resp = client.get("/_fragments/acasa")
assert resp.status_code == 200
html = resp.text
# Containerul de import trebuie sa existe ca element <details>
m = _detalii_import(html)
assert m is not None, (
"Nu s-a gasit un element <details> pentru containerul de import. "
"Implementati containerul cu <details id='import-details'>."
)
# Atributul `open` NU trebuie sa fie prezent pe <details> cand exista trimiteri
tag = m.group(0)
assert "open" not in tag.lower(), (
f"<details> are atributul 'open' cand sunt trimiteri existente — trebuie colapsat: {tag}"
)
# ============================================================
# test_import_deschis_la_first_run
# ============================================================
def test_import_deschis_la_first_run(client):
"""La first-run (zero trimiteri), containerul de import e deschis (atribut open prezent).
Serverul seteaza `open` pe <details> cand are_trimiteri=False, deci importul
e vizibil imediat fara JS.
"""
# Nu adaugam niciun submission — cont proaspat
resp = client.get("/_fragments/acasa")
assert resp.status_code == 200
html = resp.text
# Containerul de import trebuie sa existe ca element <details>
m = _detalii_import(html)
assert m is not None, (
"Nu s-a gasit un element <details> pentru containerul de import. "
"Implementati containerul cu <details id='import-details'>."
)
# Atributul `open` TREBUIE sa fie prezent pe <details> la first-run
tag = m.group(0)
assert "open" in tag.lower(), (
f"<details> NU are atributul 'open' la first-run — trebuie deschis: {tag}"
)

286
tests/test_web_filtre.py Normal file
View File

@@ -0,0 +1,286 @@
"""Teste US-004 (PRD 5.11): Rand filtre Trimiteri — layout + stil ca referinta.
TDD — testele sunt scrise INAINTE de implementare (RED), apoi devin GREEN.
Verifica:
- Quick-pills de data (Azi/7 zile/30 zile) in STANGA, inainte de campul vehicul si de pills-stare
- Pill-urile folosesc stil uniform (color-mix la hover, nu filter:brightness)
- Quick-pills seteaza data_de/data_pana si reincarca lista pastrand starea activa
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# ============================================================
# Helpers
# ============================================================
def _create_account_user(email: str, password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, f"Service {email}", 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, "csrf_token negasit pe /login"
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303, f"Login esuat: {resp.status_code}"
def _ins(acct: int, *, status: str = "needs_mapping") -> 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-us004-{os.urandom(4).hex()}",
acct,
status,
json.dumps({
"vin": "WVWZZZ1KZAW009999",
"nr_inmatriculare": "B001TST",
"data_prestatie": "2026-06-20",
"odometru_final": "100000",
"prestatii": [{"cod_prestatie": "OE-1"}],
}),
),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
# ============================================================
# Fixture
# ============================================================
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "filtre_us004.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_pill_uri_in_stanga_controalelor
# ============================================================
def test_pill_uri_in_stanga_controalelor(client):
"""Quick-pills de data (Azi/7 zile/30 zile) apar in STANGA formularului de filtre.
Ordinea in DOM: quick-pills → camp cautare vehicul → pills stare.
Pill-urile de stare NU mai stau izolate la dreapta butonului Filtreaza cu margin-left:auto
pe un span separat — layout-ul e controlat explicit prin pozitia in form.
"""
acct = _create_account_user("stanga@test.com")
_ins(acct, status="needs_mapping")
_login(client, "stanga@test.com")
resp = client.get("/?tab=acasa")
assert resp.status_code == 200
html = resp.text
# Quick-pills trebuie sa fie prezente in forma
assert "Azi" in html, "Quick-pill 'Azi' trebuie sa fie prezent in bara de filtre"
assert "7 zile" in html, "Quick-pill '7 zile' trebuie sa fie prezent in bara de filtre"
assert "30 zile" in html, "Quick-pill '30 zile' trebuie sa fie prezent in bara de filtre"
# DOM order: quick-pills STANGA (index mai mic in HTML) fata de campul vehicul
idx_azi = html.find("Azi")
idx_vehicul = html.find('id="f-vehicul"')
assert idx_azi != -1, "'Azi' nu s-a gasit in HTML"
assert idx_vehicul != -1, "'f-vehicul' nu s-a gasit in HTML"
assert idx_azi < idx_vehicul, (
"Quick-pill 'Azi' trebuie sa apara INAINTE de campul f-vehicul in DOM (stanga)"
)
# Pills stare (pills-categorii) la DREAPTA (dupa vehicul)
idx_pills_cat = html.find('id="pills-categorii"')
assert idx_pills_cat != -1, "pills-categorii nu s-a gasit in HTML"
assert idx_vehicul < idx_pills_cat, (
"Campul vehicul trebuie sa apara INAINTE de pills-categorii in DOM (pills-stare la dreapta)"
)
# Quick-pills apar si inainte de pills-categorii (stanga totala)
assert idx_azi < idx_pills_cat, (
"Quick-pills de data trebuie sa apara INAINTE de pills-categorii"
)
# ============================================================
# test_pill_categorie_stil_uniform
# ============================================================
def test_pill_categorie_stil_uniform(client):
"""Pill-urile au un singur stil uniform: hover cu color-mix, activ suprima hover.
- Hover trebuie sa foloseasca color-mix(in srgb, currentColor 12%, transparent)
si NU filter:brightness (care devenea rosu plin si ilizibil).
- Focus :focus-visible pastrat pe pill-cat.
- Pill-cat-reset activ = --accent; pill-cat activ = culoarea categoriei (nu toate accent).
"""
acct = _create_account_user("stil@test.com")
_login(client, "stil@test.com")
resp = client.get("/")
assert resp.status_code == 200
html = resp.text
# CSS-ul trebuie sa contina color-mix pentru hover pe pill-cat
assert "color-mix" in html, (
"pill-cat:hover trebuie sa foloseasca color-mix, nu filter:brightness"
)
# CSS-ul NU trebuie sa foloseasca filter:brightness pe .pill-cat:hover
m = re.search(r'\.pill-cat:hover\s*\{([^}]*)\}', html)
if m:
hover_rule = m.group(1)
assert "brightness" not in hover_rule, (
f"pill-cat:hover NU trebuie sa contina filter:brightness — regula gasita: {hover_rule}"
)
# focus-visible pastrat pe pill-cat
assert "pill-cat:focus-visible" in html, (
"pill-cat trebuie sa pastreze regula :focus-visible cu outline"
)
# Pill-cat-reset activ foloseste --accent (nu culoarea categoriei)
assert "pill-cat-reset" in html, "Clasa pill-cat-reset trebuie sa existe pentru butonul Toate"
assert "var(--accent)" in html, (
"Pill Toate activ trebuie sa foloseasca var(--accent)"
)
# ============================================================
# test_quick_pills_data_seteaza_interval
# ============================================================
def test_quick_pills_data_seteaza_interval(client):
"""Quick-pills de data seteaza data_de/data_pana (preset) si reincarca lista HTMX.
Pastrand pill-ul de stare activ: setDataRange NU schimba campul #f-status.
"""
acct = _create_account_user("datepill@test.com")
_ins(acct, status="needs_mapping")
_login(client, "datepill@test.com")
resp = client.get("/?tab=acasa")
assert resp.status_code == 200
html = resp.text
# Trebuie sa existe un mecanism JS care seteaza data_de si data_pana
assert "setDataRange" in html, (
"Quick-pills trebuie sa apeleze setDataRange (functie JS pentru setarea intervalului de date)"
)
# setDataRange trebuie sa seteze campul data_de/data_pana (prin id sau name)
assert "f-data-de" in html or "data_de" in html, (
"setDataRange trebuie sa seteze campul data_de (prin id f-data-de sau name data_de)"
)
assert "f-data-pana" in html or "data_pana" in html, (
"setDataRange trebuie sa seteze campul data_pana (prin id f-data-pana sau name data_pana)"
)
# Lista trebuie sa se reincarce prin form (HTMX) la click pe quick-pill
assert "/_fragments/submissions" in html, (
"Formularul de filtre trebuie sa trimita catre /_fragments/submissions"
)
# setDataRange NU trebuie sa schimbe campul de status (pastreaza pill-ul de stare activ)
# Verificam ca in JS-ul setDataRange nu se face `hs.value = ` (schimbare status)
# prin cautarea functiei in HTML
idx_fn = html.find("setDataRange")
assert idx_fn != -1
# Extrage corpul functiei (pana la urmatoarea definitie de functie mare)
fn_body = html[idx_fn:idx_fn + 800]
assert "f-status" not in fn_body or "hs.value" not in fn_body[:fn_body.find("f-data")], (
"setDataRange NU trebuie sa modifice campul f-status (pastreaza filtrul de stare)"
)
# ============================================================
# test_custom_pill_prezent_si_dezvaluie_campuri
# ============================================================
def test_custom_pill_prezent_si_dezvaluie_campuri(client):
"""Butonul Custom este al 4-lea quick-pill si dezvaluie campurile de data manuala.
AC US-004: Azi / 7 zile / 30 zile / Custom (4 quick-pills).
Custom NU seteaza un preset; dezvaluie #custom-date-fields cu focus pe #f-data-de.
Campurile #f-data-de/#f-data-pana sunt de tip 'date' (nu hidden) pentru interactiune.
"""
acct = _create_account_user("custom@test.com")
_ins(acct, status="needs_mapping")
_login(client, "custom@test.com")
resp = client.get("/?tab=acasa")
assert resp.status_code == 200
html = resp.text
# Butonul Custom trebuie sa fie prezent in quick-pills
assert "Custom" in html, "Butonul 'Custom' trebuie sa fie prezent in bara de filtre (4 quick-pills)"
# Butonul Custom apeleaza setDataRange cu 'custom'
assert "setDataRange" in html and "'custom'" in html, (
"Butonul Custom trebuie sa apeleze setDataRange(this,'custom')"
)
# In ramura 'custom' din JS, NU se apeleaza requestSubmit/form.submit
# (se dezvaluie campurile; utilizatorul introduce datele si form-ul submite la change)
# Cautam in JS (range === 'custom'), nu in atributul onclick al butonului
idx_fn = html.find("range === 'custom'")
assert idx_fn != -1, "Conditia `range === 'custom'` trebuie sa existe in JS (setDataRange)"
# Cautam in blocul imediat urmator conditiei: trebuie sa apara 'return'
# INAINTE de 'requestSubmit' (dovada ca nu submite automat in ramura custom)
block_custom = html[idx_fn:idx_fn + 500]
idx_return = block_custom.find("return")
idx_submit = block_custom.find("requestSubmit")
assert idx_return != -1, (
"Ramura 'custom' din setDataRange trebuie sa contina 'return' pentru a nu submite automat"
)
assert idx_submit == -1 or idx_return < idx_submit, (
"Ramura 'custom' NU trebuie sa apeleze requestSubmit inainte de 'return'"
)
# Campurile de data trebuie sa existe si sa fie de tip 'date' (nu hidden)
# pentru ca utilizatorul sa le poata interactiona in modul Custom
import re
m_de = re.search(r'<input[^>]+id="f-data-de"[^>]*>', html)
assert m_de, "Input #f-data-de negasit in HTML"
tag_de = m_de.group(0)
assert 'type="hidden"' not in tag_de, (
"Input #f-data-de NU trebuie sa fie type='hidden' — trebuie sa fie tip 'date' "
"pentru interactiune in modul Custom"
)

160
tests/test_web_mapari.py Normal file
View File

@@ -0,0 +1,160 @@
"""Teste US-002 (PRD 5.11) — scoate bifa auto_send din UI.
Verifica ca bifa "In coada automat" (macro autosend_toggle) nu mai apare in:
- tab-ul Mapari (/_fragments/mapari)
- panoul de mapare din preview (pas 3 import)
- panoul de detaliu trimitere (_trimitere_detaliu.html)
"""
from __future__ import annotations
import csv
import io
import json
import os
import re
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "us002.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
@pytest.fixture()
def conn(client):
from app.db import get_connection
c = get_connection()
yield c
c.close()
def _csv_bytes(rows: list[dict]) -> bytes:
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=";")
writer.writeheader()
writer.writerows(rows)
return buf.getvalue().encode("utf-8")
def _seed_nomenclator(conn, cod="OE-1"):
conn.execute(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, f"Operatie {cod}"),
)
conn.execute(
"INSERT OR IGNORE INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (1, 'OP-EXISTENT', ?, 1)",
(cod,),
)
conn.commit()
def test_mapari_fara_toggle_autosend(client, conn):
"""GET /_fragments/mapari nu contine checkbox auto_send sau coloana 'In coada'.
Dupa US-002: macro autosend_toggle golit; coloana In coada scoasa din toate tabelele.
"""
_seed_nomenclator(conn)
r = client.get("/_fragments/mapari")
assert r.status_code == 200
body = r.text
assert 'name="auto_send"' not in body, (
"tab-ul Mapari nu trebuie sa contina input auto_send dupa US-002"
)
assert "autosend-toggle" not in body, (
"tab-ul Mapari nu trebuie sa contina clasa autosend-toggle dupa US-002"
)
assert "In coada" not in body, (
"tab-ul Mapari nu trebuie sa contina coloana 'In coada' dupa US-002"
)
def test_preview_panou_mapare_fara_autosend(client):
"""Panoul de mapare din preview (pas 3) nu contine checkbox auto_send.
Dupa US-002: macro autosend_toggle golit din _preview_import.html.
"""
# Upload CSV cu operatie nemapata -> map coloane -> ajunge la preview
rows = [
{"VIN": "WVWZZZ1KZAW000123", "Nr inmatriculare": "B001TST",
"Data prestatie": "15.06.2026", "Odometru final": "123456",
"Operatie": "OP-NEMAPATA-US002"},
]
data = _csv_bytes(rows)
r = client.post("/_import/upload", files={"file": ("test.csv", data, "text/csv")})
assert r.status_code == 200
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m, f"Nu am gasit import_id in raspuns: {r.text[:300]}"
import_id = int(m.group(1))
# Salveaza maparea coloanelor -> genereaza preview
r = client.post(f"/_import/{import_id}/mapare-coloane", data={
"colname": ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"],
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
"format_data": "DD.MM.YYYY",
})
assert r.status_code == 200
# Obtine preview-ul HTML (ar trebui sa aiba panoul de mapare cu OP-NEMAPATA-US002)
r2 = client.get(f"/_import/{import_id}/preview")
assert r2.status_code == 200
body = r2.text
# Panoul de mapare apare (operatia nemapata e prezenta)
assert "OP-NEMAPATA-US002" in body, "preview-ul trebuie sa contina operatia nemapata"
# Fara bifa auto_send
assert 'name="auto_send"' not in body, (
"preview-ul nu trebuie sa contina input auto_send dupa US-002"
)
assert "autosend-toggle" not in body, (
"preview-ul nu trebuie sa contina clasa autosend-toggle dupa US-002"
)
assert "In coada automat" not in body, (
"preview-ul nu trebuie sa contina 'In coada automat' dupa US-002"
)
def test_detaliu_mapare_inline_fara_autosend(client, conn):
"""GET /_fragments/trimitere/{id} cu needs_mapping nu contine checkbox auto_send.
Dupa US-002: macro autosend_toggle golit din _trimitere_detaliu.html.
"""
conn.execute(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES ('OE-1', 'Test')"
)
payload = {
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B1",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [{"cod_op_service": "ITP-US002", "denumire": "Inspectie",
"cod_prestatie": None}],
}
sid = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error) "
"VALUES (?, ?, ?, ?, ?)",
("k-us002-det", 1, "needs_mapping", json.dumps(payload),
json.dumps({"unmapped": [{"cod_op_service": "ITP-US002"}]})),
).lastrowid
conn.commit()
r = client.get(f"/_fragments/trimitere/{sid}")
assert r.status_code == 200
body = r.text
# Operatia nemapata e afisata
assert "ITP-US002" in body, "detaliul trebuie sa contina operatia nemapata"
assert 'name="auto_send"' not in body, (
"detaliul nu trebuie sa contina input auto_send dupa US-002"
)
assert "autosend-toggle" not in body, (
"detaliul nu trebuie sa contina clasa autosend-toggle dupa US-002"
)

129
tests/test_web_mapeaza.py Normal file
View File

@@ -0,0 +1,129 @@
"""Teste POST /trimitere/{id}/mapeaza — US-001 auto_send ignorat.
Dupa US-001: auto_send nu mai tine randul in needs_mapping; un cod valid mapat
-> queued direct, indiferent de valoarea auto_send din form sau din mapping_meta.
"""
from __future__ import annotations
import json
import os
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, "mapeaza.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
get_settings.cache_clear()
@pytest.fixture()
def conn(client):
"""Conexiune directa la DB-ul deja initializat de client."""
from app.db import get_connection
c = get_connection()
yield c
c.close()
def _add_nomenclator(conn, cod="OE-1"):
conn.execute(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, f"Operatie {cod}"),
)
conn.commit()
def _insert_needs_mapping(conn, cod_op="ITP-CHECK"):
payload = {
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B1",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [{"cod_op_service": cod_op, "denumire": "Inspectie tehnica"}],
}
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(f"k-{os.urandom(4).hex()}", 1, "needs_mapping", json.dumps(payload)),
)
conn.commit()
return int(cur.lastrowid)
def test_mapeaza_inline_auto_send_zero_in_form_tot_queued(client, conn):
"""POST /trimitere/{id}/mapeaza cu auto_send=0 explicit in form -> queued dupa US-001.
Vechi comportament: reresolve_account vedea auto_send=0 -> review_manual -> needs_mapping.
Nou comportament: has_no_auto_send returneaza False -> queued direct.
"""
_add_nomenclator(conn)
sid = _insert_needs_mapping(conn)
r = client.post(
f"/trimitere/{sid}/mapeaza",
data={
"cod_op_service": "ITP-CHECK",
"cod_prestatie": "OE-1",
"auto_send": "0",
},
)
assert r.status_code == 200
row = conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone()
assert row["status"] == "queued", (
f"auto_send=0 in form nu trebuie sa tina randul dupa US-001, got {row['status']}"
)
def test_mapeaza_inline_fara_auto_send_form_tot_queued(client, conn):
"""POST /trimitere/{id}/mapeaza fara auto_send in form -> queued dupa US-001."""
_add_nomenclator(conn)
sid = _insert_needs_mapping(conn)
r = client.post(
f"/trimitere/{sid}/mapeaza",
data={"cod_op_service": "ITP-CHECK", "cod_prestatie": "OE-1"},
)
assert r.status_code == 200
row = conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone()
assert row["status"] == "queued", (
f"fara auto_send -> asteptat queued, got {row['status']}"
)
def test_mapeaza_inline_raspuns_fara_mesaj_auto_send(client, conn):
"""Raspunsul la mapare nu contine text despre auto-send oprit."""
_add_nomenclator(conn)
sid = _insert_needs_mapping(conn)
r = client.post(
f"/trimitere/{sid}/mapeaza",
data={"cod_op_service": "ITP-CHECK", "cod_prestatie": "OE-1", "auto_send": "0"},
)
assert r.status_code == 200
body = r.text.lower()
assert "auto-send oprit" not in body, "raspunsul nu trebuie sa contina card 'auto-send oprit'"
assert "review manual inainte de trimitere" not in body, (
"raspunsul nu trebuie sa contina mesajul vechi de auto_send"
)
def test_mapeaza_inline_trigger_trimiteri_changed(client, conn):
"""Raspunsul are header HX-Trigger=trimiteriChanged indiferent de auto_send."""
_add_nomenclator(conn)
sid = _insert_needs_mapping(conn)
r = client.post(
f"/trimitere/{sid}/mapeaza",
data={"cod_op_service": "ITP-CHECK", "cod_prestatie": "OE-1", "auto_send": "0"},
)
assert r.status_code == 200
assert r.headers.get("HX-Trigger") == "trimiteriChanged"

View File

@@ -181,17 +181,18 @@ def test_tabel_fara_poll_periodic(client):
assert "every" not in wrap, f"tabelul nu trebuie sa aiba poll periodic: {wrap}" assert "every" not in wrap, f"tabelul nu trebuie sa aiba poll periodic: {wrap}"
def test_nudge_date_noi_in_loc_de_poll(client): def test_auto_refresh_poller_la_versiune_noua(client):
"""Reimprospatarea live se face prin nudge-ul 'Date noi' (poller usor de versiune) """Reimprospatarea la date noi externe se face prin pollerul de versiune,
care NU atinge tabelul; utilizatorul reincarca explicit cand vrea.""" care cheama reincarcaTrimiteri() automat — fara nudge manual (US-008).
Nudge-ul 'Date noi. Reincarca' a fost eliminat; pollerul face auto-refresh."""
acct = _create_account_user("poll2@test.com") acct = _create_account_user("poll2@test.com")
_login(client, "poll2@test.com") _login(client, "poll2@test.com")
_insert_submission(acct) _insert_submission(acct)
html = client.get("/?tab=acasa").text html = client.get("/?tab=acasa").text
assert 'id="nudge-trimiteri"' in html, "bannerul nudge 'Date noi' trebuie sa existe" assert 'id="nudge-trimiteri"' not in html, "nudge-ul 'Date noi' trebuie eliminat (US-008)"
assert "/_fragments/trimiteri-versiune" in html, "pollerul de versiune trebuie configurat" assert "/_fragments/trimiteri-versiune" in html, "pollerul de versiune trebuie configurat"
assert "reincarcaTrimiteri" in html, "reincarcarea manuala (Reincarca) trebuie expusa" assert "reincarcaTrimiteri" in html, "auto-refresh prin reincarcaTrimiteri() trebuie prezent"
def test_trimiteriChanged_inca_reincarca(client): def test_trimiteriChanged_inca_reincarca(client):

231
tests/test_web_nav.py Normal file
View File

@@ -0,0 +1,231 @@
"""Teste US-005 (PRD 5.11): Link-uri navigatie sub contoare pe toate paginile.
TDD — testele sunt scrise INAINTE de implementare (RED), apoi devin GREEN.
Verifica:
- Sub contoare (#status-bar): rand cu link Trimiteri + Mapari (badge needs_mapping)
- Marcaj activ pe pagina curenta, via variabila de context tab_activ
- Logo ROMFAST + titlu linkeaza la / (Trimiteri)
- Hamburger capata Trimiteri (Acasa) ca prima intrare
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# ============================================================
# Helpers
# ============================================================
def _create_account_user(email: str, password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, f"Service {email}", 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, "csrf_token negasit pe /login"
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303, f"Login esuat: {resp.status_code}"
def _ins(acct: int, *, status: str = "needs_mapping") -> 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-nav-{os.urandom(4).hex()}",
acct,
status,
json.dumps({
"vin": "WVWZZZ1KZAW001001",
"nr_inmatriculare": "B001NAV",
"data_prestatie": "2026-06-20",
"odometru_final": "100000",
"prestatii": [{"cod_prestatie": "OE-1"}],
}),
),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
# ============================================================
# Fixture
# ============================================================
@pytest.fixture()
def client(monkeypatch):
"""Client cu BD izolata + auth activat (login real)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "nav_us005.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_nav_trimiteri_mapari_pe_mapari
# ============================================================
def test_nav_trimiteri_mapari_pe_mapari(client):
"""La tab=mapari, linkul Mapari e marcat activ (aria-current='page').
Linkul Trimiteri e prezent si inactiv.
Badge-ul needs_mapping apare pe linkul Mapari.
"""
acct = _create_account_user("nav1@test.com")
_ins(acct, status="needs_mapping") # badge pentru Mapari
_login(client, "nav1@test.com")
# Testam direct fragmentul status cu tab=mapari (asa il vede HTMX dupa implementare)
resp = client.get("/_fragments/status?tab=mapari")
assert resp.status_code == 200
html = resp.text
# Linkul Trimiteri (href="/") trebuie sa fie prezent
assert 'href="/"' in html, (
"Linkul Trimiteri (href='/') trebuie sa fie prezent sub contoare in #status-bar"
)
# Linkul Mapari trebuie sa fie prezent
assert '/?tab=mapari' in html, (
"Linkul Mapari (/?tab=mapari) trebuie sa fie prezent sub contoare in #status-bar"
)
# Mapari trebuie sa fie marcat activ (aria-current="page" pe elementul Mapari)
assert 'aria-current="page"' in html, (
"Linkul Mapari trebuie sa fie marcat activ cu aria-current='page' la tab=mapari"
)
# Trimiteri NU trebuie sa fie marcat activ (alta pagina e activa)
# Gasim tag-ul <a href="/"> si verificam ca NU contine aria-current
m = re.search(r'<a\b[^>]*href="/"[^>]*>', html)
assert m is not None, "Tag-ul <a href='/'> (Trimiteri) negasit in HTML"
tag_trimiteri = m.group(0)
assert 'aria-current' not in tag_trimiteri, (
f"Trimiteri NU trebuie sa fie activ la tab=mapari — tag gasit: {tag_trimiteri}"
)
# ============================================================
# test_nav_trimiteri_pe_jurnal
# ============================================================
def test_nav_trimiteri_pe_jurnal(client):
"""La tab=jurnal, ambele linkuri (Trimiteri + Mapari) sunt prezente,
dar niciunul nu e marcat activ (jurnal e pagina curenta).
"""
acct = _create_account_user("nav2@test.com")
_login(client, "nav2@test.com")
resp = client.get("/_fragments/status?tab=jurnal")
assert resp.status_code == 200
html = resp.text
# Ambele linkuri trebuie sa fie prezente
assert 'href="/"' in html, "Linkul Trimiteri (href='/') trebuie sa fie prezent la tab=jurnal"
assert '/?tab=mapari' in html, "Linkul Mapari trebuie sa fie prezent la tab=jurnal"
# La tab=jurnal, niciun link din nav nu trebuie sa aiba aria-current="page"
# (jurnal e tab-ul curent, nu Trimiteri si nici Mapari)
assert 'aria-current="page"' not in html, (
"La tab=jurnal, niciun link din status-nav nu trebuie sa fie aria-current='page'"
)
# ============================================================
# test_fragment_status_pastreaza_tab_la_self_refresh
# ============================================================
def test_fragment_status_pastreaza_tab_la_self_refresh(client):
"""Fragmentul status randat cu tab=mapari contine hx-get cu tab=mapari.
BUG 1: fara ?tab=, la poll-ul every 15s _status.html se re-randeaza fara
tab_activ -> nav-ul marcheaza gresit Trimiteri activ chiar daca userul e
pe ?tab=mapari. Fix: hx-get='/_fragments/status?tab={{ tab_activ }}'
in _status.html.
"""
acct = _create_account_user("refresh@test.com")
_login(client, "refresh@test.com")
resp = client.get("/_fragments/status?tab=mapari")
assert resp.status_code == 200
html = resp.text
# Fragmentul randat cu tab=mapari trebuie sa-si includa propriul hx-get
# cu tab=mapari, ca la self-refresh (every 15s / trimiteriChanged) sa
# primeasca acelasi tab si sa marcheze corect Mapari activ.
assert 'hx-get="/_fragments/status?tab=mapari"' in html, (
"status-bar randat cu tab=mapari trebuie sa contina hx-get cu tab=mapari "
"in el insusi (altfel self-refresh-ul pierde tab-ul activ)"
)
# ============================================================
# test_logo_linkeaza_acasa
# ============================================================
def test_logo_linkeaza_acasa(client):
"""Logo-ul ROMFAST (brand-logo) linkeaza la / (Trimiteri).
Logo-ul trebuie sa fie invelit intr-un <a href='/'> in header.
Aceasta face logo-ul clickabil pe toate paginile.
"""
acct = _create_account_user("logo@test.com")
_login(client, "logo@test.com")
resp = client.get("/")
assert resp.status_code == 200
html = resp.text
# Extrage sectiunea <header> pentru a izola cautarea
m_header = re.search(r'<header\b[^>]*>(.*?)</header>', html, re.DOTALL)
assert m_header, "<header> negasit in HTML"
header_html = m_header.group(1)
# Logo brand-logo trebuie sa fie prezent in header
assert 'brand-logo' in header_html, "Clasa brand-logo trebuie sa fie in header"
# Logo trebuie sa fie invelit intr-un <a href="/">
# Verificam: exista un <a href="/"> care contine class="brand-logo" in interiorul sau
assert re.search(r'<a\b[^>]*href="/"[^>]*>.*?brand-logo', header_html, re.DOTALL), (
"Logo-ul (class='brand-logo') trebuie sa fie intr-un <a href='/'> in header. "
"In prezent logo-ul nu e un link."
)
# Titlul "Gateway RAR AUTOPASS" trebuie sa fie si el in interiorul unui <a href="/">
# (PRD AC: Logo-ul ROMFAST + titlul linkeaza la /)
assert re.search(r'<a\b[^>]*href="/"[^>]*>.*?Gateway RAR AUTOPASS', header_html, re.DOTALL), (
"Titlul 'Gateway RAR AUTOPASS' trebuie sa fie intr-un <a href='/'> in header."
)

270
tests/test_web_refresh.py Normal file
View File

@@ -0,0 +1,270 @@
"""Teste US-008 (PRD 5.11) — Auto-refresh dupa actiuni proprii.
Comportament dorit:
1. Dupa actiune proprie (mapare inline, corectie, repune, commit), lista
Trimiteri se reincarca automat, fara click pe Reincarca.
Mecanism: server emite HX-Trigger: trimiteriChanged; #submissions-wrap
asculta 'trimiteriChanged from:body' si re-fetches imediat.
2. Filtrul activ si pagina curenta NU se reseteaza la auto-refresh.
Mecanism: hx-include="#filtre-trimiteri" pe #submissions-wrap.
3. Pollerul de fundal face auto-refresh direct la date noi (versiune diferita)
in loc sa afiseze nudge-ul "Date noi. Reincarca".
Decizie (documentata): nudge eliminat. Distinctia own vs externe nu e posibila
pe client fara sesiune dedicata — auto-refresh e mai consistent si mai simplu.
4. Elementul #nudge-trimiteri eliminat din template (dead code dupa schimbarea poller).
TDD: testele 1-2 sunt GREEN (comportament deja implementat — regresie).
testele 3-4 sunt RED→GREEN (implementate in aceeasi sesiune).
testele 5-6 (test_actiune_proprie_reincarca_automat, test_nudge_nu_mai_blocheaza)
sunt adaugate cu numele exacte din spec PRD (GREEN de la primul run — implementarea
existenta le satisface).
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixture #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "us008.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _add_submission(account_id: int = 1, status: str = "queued", payload: dict | None = None) -> int:
"""Adauga un submission si returneaza id-ul sau.
status: starea dorita (implicit 'queued'); use 'needs_mapping' pentru testele de mapare.
payload: continut JSON (implicit minimal).
"""
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"us008_key_{account_id}_{status}", account_id, status,
json.dumps(payload or {"test": True})),
)
return cur.lastrowid
finally:
conn.close()
def _add_nomenclator(cod: str = "R-FRANE", nume: str = "Reparatie frane") -> None:
"""Insereaza un cod RAR in nomenclatorul local."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?,?)",
(cod, nume),
)
finally:
conn.close()
# --------------------------------------------------------------------------- #
# Test 1 — GREEN (regresie): submissions-wrap reincarca la trimiteriChanged #
# --------------------------------------------------------------------------- #
def test_submissions_wrap_reincarca_la_trimiteriChanged(client):
"""#submissions-wrap se reincarca automat la evenimentul trimiteriChanged.
Actiunile proprii (mapare inline, corectie, repune, commit) emit
HX-Trigger: trimiteriChanged. #submissions-wrap asculta
'trimiteriChanged from:body' si re-fetches imediat, fara click pe Reincarca.
Nota: _coada.html se randeaza doar cand exista submission-uri (are_trimiteri=True).
Semanarea unui submission inainte de GET garanteaza randarea sectionii complete.
"""
_add_submission()
r = client.get("/_fragments/acasa")
assert r.status_code == 200, r.text
assert "submissions-wrap" in r.text, (
"#submissions-wrap lipseste din /_fragments/acasa (cu submission existent)"
)
assert "trimiteriChanged from:body" in r.text, (
"#submissions-wrap nu asculta 'trimiteriChanged from:body'"
"auto-refresh dupa actiuni proprii nu va functiona"
)
# --------------------------------------------------------------------------- #
# Test 2 — GREEN (regresie): filtrul nu se reseteaza la auto-refresh #
# --------------------------------------------------------------------------- #
def test_submissions_wrap_pastreaza_filtrul_la_auto_refresh(client):
"""Auto-refresh-ul nu reseteaza filtrul activ sau pagina curenta.
#submissions-wrap include #filtre-trimiteri la fiecare request HTMX.
La reincarcarea declansata de trimiteriChanged, filtrul curent (stare,
vehicul, data) si pagina curenta se retransmit automat.
"""
_add_submission()
r = client.get("/_fragments/acasa")
assert r.status_code == 200, r.text
html = r.text
assert 'hx-include="#filtre-trimiteri"' in html or "hx-include='#filtre-trimiteri'" in html, (
"hx-include=#filtre-trimiteri lipseste din #submissions-wrap — "
"filtrul se va reseta la auto-refresh"
)
# --------------------------------------------------------------------------- #
# Test 3 — RED: pollerul face auto-refresh, nu arata nudge #
# --------------------------------------------------------------------------- #
def test_poller_auto_refresh_nu_nudge(client):
"""Pollerul de fundal face auto-refresh la date noi, NU afiseaza nudge.
Pattern curent (RED): cand versiunea difera, pollerul face nudge.hidden = false.
Pattern dorit (GREEN): cand versiunea difera, pollerul cheama reincarcaTrimiteri()
care re-fetches #submissions-wrap pastrand filtrul curent.
Decizie: nudge eliminat complet (nu ramane 'doar pentru schimbari externe')
pentru ca distinctia propriu vs extern e imposibila pe client fara
mecanism de sesiune dedicat (too complex, no gain).
"""
r = client.get("/")
assert r.status_code == 200, r.text
html = r.text
# Pollerul NU mai seteaza nudge.hidden = false in handler-ul de versiune diferita.
# (Prezenta acestui pattern indica implementarea veche — RED.)
assert "nudge.hidden = false" not in html, (
"Pollerul inca arata nudge in loc de auto-refresh — "
"schimbati 'nudge.hidden = false' cu 'reincarcaTrimiteri()' in base.html"
)
# --------------------------------------------------------------------------- #
# Test 4 — RED: nudge-ul e eliminat din template #
# --------------------------------------------------------------------------- #
def test_nudge_eliminat_din_lista(client):
"""Elementul #nudge-trimiteri este eliminat din template-ul listei.
Dupa ce pollerul trece la auto-refresh, nudge-ul devine dead code.
Eliminarea lui simplifica template-ul si elimina un element de UI confuz
(utilizatorul vedea 'Date noi. Reincarca' chiar daca lista era actuala,
din cauza refreshului propriu care nu actualiza versiunea fast enough).
Nota: _coada.html se randeaza doar cand sunt submission-uri. Semanarea
asigura ca _coada.html e inclus in raspuns (altfel testul ar pasa vacuos).
"""
_add_submission()
r = client.get("/_fragments/acasa")
assert r.status_code == 200, r.text
assert "nudge-trimiteri" not in r.text, (
"#nudge-trimiteri inca exista in template — "
"eliminati elementul din _coada.html dupa ce pollerul e migrat la auto-refresh"
)
# --------------------------------------------------------------------------- #
# Test 5 (spec PRD): actiune proprie reincarca automat fara click Reincarca #
# --------------------------------------------------------------------------- #
def test_actiune_proprie_reincarca_automat(client):
"""Dupa o actiune proprie (mapare inline), lista se reincarca automat.
Verifica doua componente ale mecanismului:
1. Server-side: POST la mapare inline returneaza HX-Trigger: trimiteriChanged
in headerele raspunsului (indiferent de starea noua a submission-ului).
2. Client-side: #submissions-wrap asculta 'trimiteriChanged from:body' in
hx-trigger — HTMX va declansa re-fetch imediat la primirea headerului,
fara click pe Reincarca.
Testeaza calea mapare-inline; corectie/repune/commit sunt acoperite similar
(toate emit HX-Trigger: trimiteriChanged — verificate in test_import_commit.py).
"""
_add_nomenclator("R-FRANE", "Reparatie frane")
sub_id = _add_submission(
status="needs_mapping",
payload={"prestatii": [{"cod_op_service": "OP-FRANE"}]},
)
# POST mapare inline — emite HX-Trigger: trimiteriChanged
r = client.post(
f"/trimitere/{sub_id}/mapeaza",
data={"csrf_token": "", "cod_op_service": "OP-FRANE", "cod_prestatie": "R-FRANE"},
)
assert r.status_code == 200, r.text
# Server emite triggerul — HTMX va declansa auto-refresh pe client
hx = r.headers.get("HX-Trigger", "")
assert "trimiteriChanged" in hx, (
f"Mapare inline nu emite HX-Trigger: trimiteriChanged — "
f"lista nu se va reincarca automat. Header primit: {hx!r}"
)
# Client-side: #submissions-wrap asculta triggerul (markup existent in template)
r_acasa = client.get("/_fragments/acasa")
assert "trimiteriChanged from:body" in r_acasa.text, (
"#submissions-wrap nu asculta 'trimiteriChanged from:body'"
"auto-refresh nu va functiona dupa actiune proprie"
)
# --------------------------------------------------------------------------- #
# Test 6 (spec PRD): nudge nu mai blocheaza actualizarea #
# --------------------------------------------------------------------------- #
def test_nudge_nu_mai_blocheaza_actualizarea(client):
"""Nudge-ul 'Date noi. Reincarca' nu mai poate bloca actualizarea listei.
Inainte de US-008, mecanismul era: poller detecteaza versiune diferita →
arata nudge → utilizatorul trebuia sa apese 'Reincarca' manual.
Dupa US-008, nudge-ul e eliminat (decizie documentata mai jos) si pollerul
cheama direct reincarcaTrimiteri() → actualizare automata, fara click.
Decizie privind nudge: ELIMINAT complet (nu pastrat pentru schimbari externe).
Rationale: distinctia 'actiune proprie vs schimbare externa' nu e posibila
pe client fara mecanism dedicat de sesiune. Auto-refresh periodic (la versiune
diferita) acopera si schimbarile externe (worker, alt browser) fara friction.
"""
_add_submission() # are_trimiteri=True → _coada.html se randeaza
r_pagina = client.get("/_fragments/acasa")
assert r_pagina.status_code == 200, r_pagina.text
html = r_pagina.text
# Nudge eliminat: nu mai poate bloca utilizatorul cu un banner "Reincarca"
assert "nudge-trimiteri" not in html, (
"#nudge-trimiteri inca exista — poate bloca actualizarea (US-008 il elimina)"
)
# Pollerul JS nu mai seteaza nudge.hidden = false: nu poate "bloca" prin afisare nudge
r_home = client.get("/")
assert r_home.status_code == 200, r_home.text
assert "nudge.hidden = false" not in r_home.text, (
"Pollerul JS inca poate afisa nudge-ul — schimbati cu reincarcaTrimiteri()"
)
# Pollerul cheama reincarcaTrimiteri() la versiune diferita (auto-refresh)
assert "reincarcaTrimiteri" in r_home.text, (
"reincarcaTrimiteri() lipseste din poller — auto-refresh nu va functiona"
)

View File

@@ -1,6 +1,5 @@
"""Teste PRD 5.5 — uniformizare UI: US-001 (Acasa fara Ajutor), US-002 (Nomenclator grila """Teste PRD 5.5 — uniformizare UI: US-001 (Acasa fara Ajutor), US-002 (Nomenclator grila
standard), US-003 (macro autosend compact). Stories de template/macro -> render direct Jinja standard), US-003 (macro autosend) — actualizat dupa PRD 5.11 US-002 (macro neutralizat).
pentru US-002/003; US-001 prin TestClient pe fragmentul Acasa.
""" """
from __future__ import annotations from __future__ import annotations
@@ -53,32 +52,26 @@ def _render_macro(form_id="map-1", checked=True):
def test_autosend_pastreaza_name_si_prezenta(): def test_autosend_pastreaza_name_si_prezenta():
"""Invariant backend: checkbox name=auto_send value=true (semantica de prezenta).""" """US-002 (PRD 5.11): macro autosend_toggle neutralizat — output gol, fara checkbox."""
html = _render_macro(checked=True) html = _render_macro(checked=True)
assert 'type="checkbox"' in html assert 'name="auto_send"' not in html, "US-002: checkbox auto_send scos din macro"
assert 'name="auto_send"' in html assert html.strip() == "", f"macro neutralizat trebuie sa intoarca string gol, got: {html!r}"
assert 'value="true"' in html
assert 'form="map-1"' in html
assert "checked" in html
def test_autosend_nebifat_fara_checked(): def test_autosend_nebifat_fara_checked():
"""US-002: macro neutralizat intoarce gol indiferent de checked."""
html = _render_macro(checked=False) html = _render_macro(checked=False)
assert 'name="auto_send"' in html assert 'name="auto_send"' not in html
assert "checked" not in html assert html.strip() == ""
def test_autosend_compact_fara_proza_inline(): def test_autosend_compact_fara_proza_inline():
"""Proza explicativa de pe randuri (3.6) eliminata din CONTINUTUL vizibil — traieste in """US-002: macro neutralizat nu contine nicio proza inline."""
panoul Ajutor (US-005). Tooltip-ul scurt (atribut title=) e acceptat, deci il scoatem
inainte de verificare."""
html = _render_macro() html = _render_macro()
vizibil = re.sub(r'title="[^"]*"', "", html) # scoate atributul title (tooltip) assert "La fisierele viitoare" not in html
assert "La fisierele viitoare" not in vizibil assert "Tine pentru verificare" not in html
assert "Tine pentru verificare" not in vizibil assert "nimic nu pleaca la RAR" not in html
assert "nimic nu pleaca la RAR" not in vizibil assert html.strip() == ""
# ambele etichete de stare vizibile, compact
assert "Auto" in html and "Manual" in html
# ============================================================ # ============================================================
@@ -155,16 +148,16 @@ def test_mapari_ajutor_disclosure_si_fara_proza_inline(client):
html = resp.text html = resp.text
# US-010: sectiunea de ajutor (<details class="ajutor-mapari">) eliminata # US-010: sectiunea de ajutor (<details class="ajutor-mapari">) eliminata
assert "ajutor-mapari" not in html assert "ajutor-mapari" not in html
# antet de coloana compact # US-002: coloana In coada scoasa din tabel
assert ">In coada<" in html assert "In coada" not in html, "US-002: coloana 'In coada' scoasa din tabelul Mapari"
# proza inline veche eliminata de pe sectiuni # proza inline veche eliminata de pe sectiuni
assert "sugestia fuzzy e preselectata) si salveaza" not in html assert "sugestia fuzzy e preselectata) si salveaza" not in html
assert "Maparile operatie -> cod RAR retinute pentru contul tau" not in html assert "Maparile operatie -> cod RAR retinute pentru contul tau" not in html
def test_mapari_comutator_compact_in_tabel(client): def test_mapari_comutator_compact_in_tabel(client):
"""US-002: tabul Mapari nu mai contine checkbox auto_send."""
acct = _login(client) acct = _login(client)
_seed_needs_mapping(acct) _seed_needs_mapping(acct)
html = client.get("/_fragments/mapari").text html = client.get("/_fragments/mapari").text
assert 'name="auto_send"' in html assert 'name="auto_send"' not in html, "US-002: checkbox auto_send scos din UI"
assert "Manual" in html and "Auto" in html