feat(5.10): UX trimiteri (pill filtre, paginare, editare) + Mapari in meniu + branding ROMFAST

14 stories TDD prin echipa de workeri (lead orchestreaza, 3 teammates pe valuri cu fisiere disjuncte; routes.py + base.html serializate ca fisiere fierbinti).

- US-001 fix filtrare data (_iso_date_prefix pe garda+comparatie, prinde timestamp cu ora)
- US-002/007 operatie service distincta in payload_view + afisare in detaliu
- US-003 pill-uri categorii (button/aria-pressed; needs_mapping --warn, needs_data/error --err); fara lista ID-uri/dropdown
- US-004 paginare numerotata 25/pag (total ramificat SQL-COUNT vs fetch-all+slice, clamp page, poll pastreaza pagina)
- US-005 VIN block-level sub nr
- US-006/006b editare cod RAR + validare nomenclator + recalcul idempotency (needs_data/needs_mapping via /corecteaza, error via /repune)
- US-008 card eroare 3-niveluri doar pe read-only + rezumat top-of-form
- US-009 Mapari in meniu hamburger; scoatere tab-bar + role=tablist orfan
- US-010/011 pagina Mapari consolidata + butoane icon SVG + dirty-state (fara kebab/emoji)
- US-012/012b header centrat + logo ROMFAST (/static/romfast_logo.png) in header
- US-013 paleta azur ROMFAST (#2E74D6/#1F66C9) + IBM Plex Sans/Mono self-host (woff2 reale)
- US-014 selector tema ciclic Light/Dark/Petrol/Auto + anti-FOUC pe 4 stari

Backend trimitere (worker/masina stari/idempotenta/mapping) + schema NEATINSE (UI/UX pur + 1 fix de filtrare).
VERIFY context curat PASS; /code-review high: 1 finding material reparat (US-006b). Regresie 896 passed, 1 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-25 20:20:58 +00:00
parent 3bc0825e0b
commit 5a964a1a8d
43 changed files with 3949 additions and 414 deletions

View File

@@ -13,6 +13,7 @@ from __future__ import annotations
import hashlib
import json
import math
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
@@ -555,43 +556,26 @@ def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]:
return rezultat
# Cate randuri blocate identificam nominal sub fiecare categorie din banner (US-014).
_BLOCATE_SAMPLE = 3
def _pills_categorii(counts: dict[str, int]) -> list[dict]:
"""Pill-uri pentru starile cu problema (US-003 PRD 5.10).
def _blocate_actionabil(conn, account_id: int) -> list[dict]:
"""Categorii blocate cu identificatorii primelor randuri + deep-link (US-014).
Pentru fiecare stare blocata cu n>0: eticheta umana, contorul, primii N identificatori
(VIN partial + nr inmatriculare + #id — PII doar partial, ca jurnalul) si cati raman.
Scoped pe cont (regula NULL->1). Lista goala -> banner-ul nu se randeaza (se stinge).
Inlocuieste _blocate_actionabil (care incarca PII/VIN per rand).
Reutilizeaza contoarele deja calculate din _status_counts.
Returneza lista goala daca nu exista nicio stare blocata.
"""
from ..security import vin_partial
scope_sql, scope_params = account_scope_clause(account_id)
out: list[dict] = []
for status in ("needs_mapping", "needs_data", "error"):
rows = conn.execute(
f"SELECT id, payload_json FROM submissions WHERE {scope_sql} AND status=? ORDER BY id DESC",
scope_params + [status],
).fetchall()
if not rows:
continue
sample = []
for r in rows[:_BLOCATE_SAMPLE]:
prez = prezentare_din_payload(r["payload_json"])
sample.append({
"id": r["id"],
"vin": vin_partial(prez.get("vin") or ""),
"nr": prez.get("vehicul_nr") or "",
})
out.append({
"status": status,
"eticheta": eticheta_stare(status),
"n": len(rows),
"randuri": sample,
"rest": max(0, len(rows) - len(sample)),
})
return out
# DESIGN.md §Componente: Lipsa cod = --warn (chihlimbar), celelalte categorii = --err (rosu).
# Culoarea e CSS variable name (nu clasa), injectata direct in style tag al pill-ului,
# pentru ca s-needs_mapping in base.html e tot --err (incorect pentru pill).
PILL_DEFS = [
("needs_mapping", "Lipsa cod", "--warn"),
("needs_data", "Date incomplete", "--err"),
("error", "Eroare", "--err"),
]
return [
{"status": status, "label": label, "color_var": color_var, "n": counts.get(status, 0)}
for status, label, color_var in PILL_DEFS
if counts.get(status, 0) > 0
]
@router.get("/_fragments/status", response_class=HTMLResponse)
@@ -630,23 +614,31 @@ def fragment_status(request: Request) -> HTMLResponse:
"counts_sent": counts.get("sent", 0),
"blocate_total": blocate_total,
"blocate_defalcat": _blocate_defalcat(counts),
"blocate_actionabil": _blocate_actionabil(conn, account_id),
"pills_categorii": _pills_categorii(counts),
"account_active": _account_active(conn, account_id),
})
finally:
conn.close()
def _is_iso_date(value: object) -> bool:
"""True daca `value` e o data ISO YYYY-MM-DD (comparabila lexicografic corect)."""
def _iso_date_prefix(value: object) -> str | None:
"""Intoarce primele 10 caractere (YYYY-MM-DD) daca incep cu o data ISO valida, altfel None.
Permite filtrarea dupa data_prestatie chiar daca valoarea contine ora/minut/secunda
(ex. '2026-06-20 14:35:07' sau '2026-06-20T14:35:07') — extrage portiunea de data
fara a exclude timestamp-urile (bug-ul fix US-001: _is_iso_date cerea len==10).
Valori care nu incep cu o data ISO valida (ex. '05.12.2024') intorc None si
sunt excluse din filtru — comportament actual pastrat.
"""
s = str(value or "").strip()
if len(s) != 10:
return False
if len(s) < 10:
return None
prefix = s[:10]
try:
datetime.strptime(s, "%Y-%m-%d")
return True
datetime.strptime(prefix, "%Y-%m-%d")
return prefix
except (ValueError, TypeError):
return False
return None
# Stari care semnaleaza o problema ce necesita atentia operatorului. Eticheta umana
@@ -693,6 +685,9 @@ def _submission_row_view(r) -> dict:
}
_PAGE_SIZE = 25 # Marime pagina fixa (US-004 PRD 5.10)
@router.get("/_fragments/submissions", response_class=HTMLResponse)
def fragment_submissions(
request: Request,
@@ -700,12 +695,14 @@ def fragment_submissions(
vehicul: str | None = None,
data_de: str | None = None,
data_pana: str | None = None,
page: int = 1,
) -> HTMLResponse:
"""Tabel Trimiteri, scoped pe cont, cu filtre optionale (US-009).
"""Tabel Trimiteri, scoped pe cont, cu filtre optionale si paginare (US-009, US-004).
Filtrarea pe stare se face in SQL (foloseste idx_submissions_account_status);
filtrarea pe vehicul (nr/VIN, case-insensitive) si pe interval data_prestatie
se face dupa parsarea payload_json in Python (plafon perf notat — eng review).
US-004 H1: totalul se calculeaza DIFERIT dupa tipul de filtru:
- FARA filtru Python (status-only / niciun filtru): SQL COUNT(*) + LIMIT/OFFSET
- CU filtru vehicul/data activ: fetch-all -> filtreaza Python -> total=len -> slice
SQL COUNT/LIMIT pe calea cu filtru Python ar da total gresit (taie inainte de filtru).
"""
account_id = require_login(request)
status = (status or "").strip() or None
@@ -713,6 +710,9 @@ def fragment_submissions(
data_de = (data_de or "").strip() or None
data_pana = (data_pana or "").strip() or None
filtru_activ = bool(status or vehicul_q or data_de or data_pana)
filtru_python = bool(vehicul_q or data_de or data_pana) # filtru care necesita Python
page = max(1, page) # pre-clamp >= 1
conn = get_connection()
try:
@@ -722,45 +722,79 @@ def fragment_submissions(
if status:
where.append("status=?")
params.append(status)
# Filtrarea pe vehicul/data se face in Python (dupa parsarea payload). Daca am
# taia la LIMIT inainte de filtru, am rata silentios randuri mai vechi care
# potrivesc. Cand un filtru text/data e activ, scoatem LIMIT-ul din SQL si plafonam
# afisarea dupa filtrare (OK la scara actuala — plafon perf notat, eng review).
limit_sql = "" if (vehicul_q or data_de or data_pana) else " LIMIT 200"
rows = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
"updated_at, payload_json FROM submissions "
f"WHERE {' AND '.join(where)} ORDER BY id DESC{limit_sql}",
params,
).fetchall()
where_sql = " AND ".join(where)
view = []
for r in rows:
v = _submission_row_view(r)
prez = v["prez"]
if vehicul_q:
hay = f"{prez['vehicul_nr']} {prez['vin']}".upper()
if vehicul_q not in hay:
continue
if data_de or data_pana:
d = prez["data_prestatie"]
# Comparam doar date in format ISO (YYYY-MM-DD); altfel comparatia de string
# ar fi gresita (ex. "05.12.2024"). Valori ne-ISO sunt excluse din filtru.
if not _is_iso_date(d):
continue
if data_de and d < data_de:
continue
if data_pana and d > data_pana:
continue
view.append(v)
if len(view) >= 200:
break
if filtru_python:
# Calea B: fetch-all, filtreaza in Python, slice (US-004 H1)
# FARA LIMIT — altfel paginile >8 ar disparea silentios (bug PRD H1)
rows_db = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC",
params,
).fetchall()
view_all: list[dict] = []
for r in rows_db:
v = _submission_row_view(r)
prez = v["prez"]
if vehicul_q:
hay = f"{prez['vehicul_nr']} {prez['vin']}".upper()
if vehicul_q not in hay:
continue
if data_de or data_pana:
# Extragem portiunea YYYY-MM-DD (US-001 fix).
d_prefix = _iso_date_prefix(prez["data_prestatie"])
if d_prefix is None:
continue
if data_de and d_prefix < data_de:
continue
if data_pana and d_prefix > data_pana:
continue
view_all.append(v)
total = len(view_all)
pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1
page = max(1, min(page, pages)) # clamp H2
offset = (page - 1) * _PAGE_SIZE
view = view_all[offset:offset + _PAGE_SIZE]
else:
# Calea A: SQL COUNT(*) + LIMIT/OFFSET (eficient, fara filtru Python activ)
total = conn.execute(
f"SELECT COUNT(*) FROM submissions WHERE {where_sql}", params
).fetchone()[0]
pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1
page = max(1, min(page, pages)) # clamp H2
offset = (page - 1) * _PAGE_SIZE
rows_db = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC "
"LIMIT ? OFFSET ?",
params + [_PAGE_SIZE, offset],
).fetchall()
view = [_submission_row_view(r) for r in rows_db]
page_start = (page - 1) * _PAGE_SIZE + 1 if total > 0 else 0
page_end = min(page * _PAGE_SIZE, total)
return templates.TemplateResponse("_submissions.html", {
"request": request,
"rows": view,
"filtru_activ": filtru_activ,
"csrf_token": get_csrf_token(request),
# Paginare (US-004)
"total": total,
"page": page,
"pages": pages,
"page_start": page_start,
"page_end": page_end,
# Filtre curente pentru linkurile de paginare (pastreaza filtrele, H2)
"f_status": status or "",
"f_vehicul": vehicul_q or "",
"f_data_de": data_de or "",
"f_data_pana": data_pana or "",
})
finally:
conn.close()
@@ -768,6 +802,9 @@ def fragment_submissions(
# Stari ne-trimise blocate pe care le putem corecta inline (US-010).
_CORECTABILE = ("needs_data", "needs_mapping")
# US-006b: stari cu select editabil cod_prestatie (superset al _CORECTABILE: error
# primeste select in formularul /repune, nu in /corecteaza — fara schimbare de vehicle fields).
_EDITABILE_OP = ("needs_data", "needs_mapping", "error")
# Stari gestionabile prin lifecycle web (US-011): sterge / re-pune in coada.
_GESTIONABILE_WEB = ("error", "needs_data", "needs_mapping")
@@ -851,12 +888,31 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
eticheta = eticheta_stare(row["status"])
nemapate_inline: list[dict] = []
nomenclator: list[dict] = []
# Variabila interna: nomenclatorul complet (incarcat pentru needs_mapping, refolosit pt US-006)
_nomenclator_complet: list[dict] = []
if conn is not None and row["status"] == "needs_mapping":
# Un singur SELECT pe nomenclator: il refolosim si pentru sugestii si pentru dropdown.
nomenclator = load_nomenclator(conn)
nemapate_inline = _nemapate_pentru_submission(row, nomenclator)
if not nemapate_inline:
nomenclator = [] # nu expunem dropdown-ul cand nu exista operatii de mapat
_nomenclator_complet = load_nomenclator(conn)
nemapate_inline = _nemapate_pentru_submission(row, _nomenclator_complet)
nomenclator = _nomenclator_complet if nemapate_inline else []
# US-006/US-006b: nomenclator pentru selectul cod_prestatie — needs_data/needs_mapping (in
# formularul /corecteaza) + error (in formularul /repune). Refoloseste _nomenclator_complet
# daca e deja incarcat (needs_mapping), altfel incarca fresh.
nomenclator_rar: list[dict] = []
if conn is not None and row["status"] in _EDITABILE_OP:
nomenclator_rar = _nomenclator_complet if _nomenclator_complet else load_nomenclator(conn)
# US-006: cod_prestatie curent din prima prestatie (pentru pre-selectare in select)
cod_prestatie_curent = ""
try:
_pd = json.loads(row["payload_json"] or "{}")
_prestatii = (_pd.get("prestatii") or []) if isinstance(_pd, dict) else []
if _prestatii and isinstance(_prestatii[0], dict):
cod_prestatie_curent = (_prestatii[0].get("cod_prestatie") or "").strip().upper()
except (ValueError, TypeError):
pass
ctx = {
"request": request,
"csrf_token": get_csrf_token(request),
@@ -882,6 +938,9 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
# PRD 5.7: mapare inline (operatii nemapate ale acestui rand + nomenclator)
"nemapate_inline": nemapate_inline,
"nomenclator": nomenclator,
# US-006: select cod_prestatie pentru stari editabile
"nomenclator_rar": nomenclator_rar,
"cod_prestatie_curent": cod_prestatie_curent,
"corectie_msg": message,
"corectie_error": error,
"corectie_errors": corectie_errors or [],
@@ -1011,6 +1070,31 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
if isinstance(val, str) and val.strip() != "":
content[camp] = val.strip()
# US-006: injectare cod_prestatie din form INAINTE de resolve_prestatii.
# Oglindeste validarea din post_mapeaza_inline (nomenclator check). Codul nou
# e injectat in prima prestatie (index 0); build_key il include in hash (CLAUDE.md
# invariant "build_key hashuieste cod_prestatie, idempotency.py:34").
_cod_raw = form.get("cod_prestatie")
cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "")
if cod_prestatie_form:
exists_nom = conn.execute(
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie_form,)
).fetchone()
if not exists_nom:
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(
request, row, conn=conn, account_id=account_id, error=True,
message=f"Cod RAR necunoscut in nomenclator: {cod_prestatie_form}. "
"Alege un cod valid din lista.",
),
)
prestatii_form = content.get("prestatii")
if isinstance(prestatii_form, list) and prestatii_form:
p0 = dict(prestatii_form[0])
p0["cod_prestatie"] = cod_prestatie_form
content["prestatii"] = [p0] + list(prestatii_form[1:])
# Re-rezolva prestatiile cu maparea curenta (ca reresolve_account): NU re-pune
# niciodata in coada un cod nemapat (codPrestatie null) — FINALIZATA e ireversibil
# la RAR. Corectia campurilor de continut nu poate deebloca o operatie nemapata.
@@ -1133,14 +1217,114 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
async def post_repune_trimitere(request: Request, submission_id: int) -> HTMLResponse:
"""Re-pune in coada un rand blocat (error/needs_data/needs_mapping) din dashboard.
Scoped pe sesiune (404 cross-account/inexistent, 409 sent/sending). Re-randeaza
panoul de detaliu cu starea noua + nudge `trimiteriChanged` pentru lista.
US-006b: daca randul e in starea `error` si formularul contine `cod_prestatie`,
actualizeaza codul in payload, recalculeaza cheia de idempotency si re-pun in coada
direct (fara `requeue_submission`, care nu actualizeaza cheia). Scoped pe sesiune
(404 cross-account/inexistent, 409 sent/sending). Re-randeaza panoul de detaliu cu
starea noua + nudge `trimiteriChanged` pentru lista.
"""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
conn = get_connection()
try:
# US-006b: prelucrare cod_prestatie pentru starea error (inaintea requeue_submission
# standard, care nu actualizeaza cheia de idempotency).
_cod_raw = form.get("cod_prestatie")
cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "")
if cod_prestatie_form:
row = _fetch_submission_scoped(conn, account_id, submission_id)
if not row:
raise HTTPException(status_code=404, detail="trimitere inexistenta")
if row["status"] != "error":
# cod_prestatie acceptat DOAR pentru starea error prin /repune
raise HTTPException(
status_code=409,
detail="modificarea cod_prestatie prin repune e valida doar pentru starea error",
)
# Valideaza cod-ul fata de nomenclator
exists_nom = conn.execute(
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie_form,)
).fetchone()
if not exists_nom:
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(
request, row, conn=conn, account_id=account_id,
error=True,
message=f"Cod RAR necunoscut: {cod_prestatie_form}. Alege un cod valid.",
),
)
# Parseaza payload si injecteaza cod_prestatie
try:
content = json.loads(row["payload_json"]) if row["payload_json"] else {}
if not isinstance(content, dict):
content = {}
except (ValueError, TypeError):
content = {}
prestatii = content.get("prestatii") or []
if isinstance(prestatii, list) and prestatii:
p0 = dict(prestatii[0])
p0["cod_prestatie"] = cod_prestatie_form
# sterge cod_op_service/denumire daca exista (codul direct preia prioritate)
p0.pop("cod_op_service", None)
content["prestatii"] = [p0] + list(prestatii[1:])
# Re-rezolva prestatii cu noul cod
mapping_meta = load_mapping_meta(conn, account_id)
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, account_id)
resolved, _unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules)
content["prestatii"] = resolved
# Canonicalize + rebuild idempotency key
canon = canonicalize_row(content)
payload_json = json.dumps(content, ensure_ascii=False)
new_key = build_key(account_id, canon)
# Verifica coliziune (numai daca cheia s-a schimbat)
if new_key != row["idempotency_key"]:
dup = conn.execute(
"SELECT id FROM submissions WHERE idempotency_key=? AND id<>?",
(new_key, row["id"]),
).fetchone()
if dup:
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(
request, row, conn=conn, account_id=account_id,
error=True,
message=f"Exista deja o trimitere identica (rand #{dup['id']}).",
),
)
try:
conn.execute(
"UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, "
"rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), "
"updated_at=datetime('now') WHERE id=? AND account_id=?",
(new_key, payload_json, row["id"], account_id),
)
conn.commit()
except sqlite3.IntegrityError:
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(
request, row, conn=conn, account_id=account_id,
error=True,
message="Exista deja o trimitere identica. Operatia a fost oprita.",
),
)
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
resp = templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(
request, row2, conn=conn, account_id=account_id,
message="Cod actualizat — randul a fost re-pus in coada.",
),
)
resp.headers["HX-Trigger"] = "trimiteriChanged"
return resp
# Cale normala: fara cod_prestatie → delega la requeue_submission
try:
requeue_submission(conn, account_id, submission_id)
except SubmissionNotFound: