Compare commits

...

5 Commits

Author SHA1 Message Date
Claude Agent
c31a1e254c chore(deploy): docker-compose api pe prod + worker send activat
- api: AUTOPASS_RAR_ENV test -> prod; scos maparea de port 8010:8010 (acces prin reverse proxy)
- worker: AUTOPASS_WORKER_SEND_ENABLED false -> true (trimitere efectiva la RAR)

Modificari facute de utilizator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:49:43 +00:00
Claude Agent
4a2afc68bf chore: curatare agresiva comentarii — scoatere referinte US/PRD din cod si template-uri
Eliminat zgomotul de trasabilitate (US-xxx, PRD x.x, Rn, OV-x, Tn, decizii/naratiune
istorica) din 41 fisiere app/ + template-uri. Pastrate comentariile care documenteaza
invarianti si logica ne-evidenta (idempotenta/hash, reconciliere anti-duplicat, RAR 500
esec definitiv, creds per cont, WAF User-Agent, 422 fara echo de parola, scope NULL->1),
curatate doar de tokeni.

Verificare: pentru cele 27 module .py curatate, structura de cod (tokeni non-comentariu/
non-string) e IDENTICA fata de HEAD -> doar comentarii/docstring-uri schimbate. Singura
schimbare de cod e in tests/test_web_responsive.py (scos 3 assert pe markeri US-006/007/008,
inlocuite de asertiunile structurale alaturate). 0 tokeni US/PRD reziduali in app/.
Regresie: 896 passed, 1 deselected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:44:24 +00:00
Claude Agent
f05fe5b221 fix(5.11): tabel trimiteri stabil — bug status=None, pills in bara de filtre, nudge "Date noi" in loc de poll 15s, logo ROMFAST marit
- Fix bug: campul hidden de filtru randa literal "None" (status_filtru None +
  Jinja default('')) -> poll-ul trimitea status=None -> tabel gol. status or "".
- Pills de stare mutate din bara de status in bara de filtre (filtreazaStare scrie
  campul hidden + re-trimite form-ul; filtrul persista la reincarcari). Re-randate
  OOB cu contoare proaspete la fiecare reincarcare a tabelului.
- Polling redesign: tabelul nu se mai reincarca singur (fara every 15s). Poller usor
  JSON (/_fragments/trimiteri-versiune) detecteaza schimbari -> nudge "Date noi —
  Reincarca". Reincarcarea (nudge / actiune) pastreaza filtrul+pagina. Scroll/selectia
  nu se mai pierd. Poll-guard eliminat (nu mai exista poll periodic de pauzat).
- Logo ROMFAST 32px -> 60px (ca pe romfast.ro), header min-height 92px, 44px pe mobil.

Regresie: 896 passed, 1 deselected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:13:42 +00:00
Claude Agent
074b6e7c8a fix(5.10): logo ROMFAST in stanga header (ca romfast.ro) + tooltip tema doar numele temei
- US-012c: logo .brand-logo mutat in header-left (32px, aliniat stanga); env badge mutat sub titlu in header-center; titlul ramane centrat; responsiv pastrat.
- US-014b: title-ul butonului de tema = doar numele temei curente (Light/Dark/Petrol/Auto), fara enumerarea ciclului; aria-label informativ + aria-live pastrate (a11y).

Regresie 896 passed, 1 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:37:00 +00:00
Claude Agent
5a964a1a8d 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>
2026-06-25 20:20:58 +00:00
81 changed files with 4577 additions and 1078 deletions

150
DESIGN.md Normal file
View File

@@ -0,0 +1,150 @@
# DESIGN.md — Sistem de design AutoPass (by ROMFAST)
> Sursa de adevar pentru identitatea vizuala a dashboard-ului. Implementarea concreta sta in
> `app/web/templates/base.html` (variabile CSS `:root` + `[data-theme="light"]`). Acest fisier
> spune *ce* si *de ce*; base.html spune *cum*.
## Lucrul de retinut
> „Software serios pentru o obligatie legala serioasa — dar parte din familia ROMFAST/ROA, nu un
> tool anonim." Operatorul de service trebuie sa simta ca declara la stat printr-un instrument de
> incredere, cu identitatea producatorului (ROMFAST) prezenta discret, nu griul generic de azi.
## Context produs
Gateway web care declara prezentari de service-auto la RAR AUTOPASS (L.142/2023). Utilizatori:
operatori de service-auto si integratori ROAAUTO. Face parte din familia **ROA — Romfast
Applications** (ERP romanesc, modul Service Auto). Referinta de brand: **romfast.ro** — alb curat,
accent albastru azur, pill-uri rotunjite, comutator de tema, logo rosu+albastru.
## Decizie cromatica
Accentul functional = **albastrul ROMFAST** (acelasi cu „FAST" din logo si cu accentul de pe
romfast.ro), nu albastrul generic SaaS de pana acum. Rosul apare DOAR in wordmark-ul „ROM" — nu ca
accent de UI, fiindca rosul e rezervat starilor de eroare. Un singur accent, restul neutre, ca
sistemul sa ramana discret.
### Paleta — Dark (default)
```
--bg: #0f1218 fundal aplicatie
--card: #181c24 suprafete (carduri, modal, inputuri pe fundal)
--ink: #e6e9ef text principal
--muted: #8b93a7 text secundar (label-uri, coduri, „by")
--line: #262b36 borduri, separatoare
--accent:#2E74D6 azur ROMFAST — butoane primare, pill activ, linkuri, focus
--ok: #2FBF8F sent / succes
--warn: #E0A93B sending / atentie / Lipsa cod
--err: #E05D5D error / needs_data / Date incomplete
```
### Paleta — Light (`[data-theme="light"]`)
```
--bg: #f5f7fa fundal (alb-rece ca romfast.ro)
--card: #ffffff suprafete
--ink: #1a1d24 text principal
--muted: #5c6473 text secundar
--line: #e2e5ea borduri
--accent:#1F66C9 azur, variantă mai inchisa pentru contrast AA pe alb
--ok: #15803d verde AA pe alb
--warn: #b45309 chihlimbar AA pe alb
--err: #dc2626 rosu AA pe alb
```
### Paleta — Petrol (`[data-theme="petrol"]`, tema selectabila)
Tema intunecata alternativa, cu accent petrol-teal (directia initiala aleasa, pastrata ca optiune).
Aceleasi neutre-calde inchise; doar accentul difera de azur.
```
--bg: #0e1416 fundal petrol-inchis
--card: #161e20 suprafete
--ink: #e6e9ef text principal
--muted: #8b93a7 text secundar
--line: #232c2e borduri
--accent:#0E7C7B teal petrol — butoane, pill activ, linkuri, focus
--ok: #2FBF8F sent
--warn: #E0A93B atentie
--err: #E05D5D eroare
```
### Culori de brand (doar wordmark, NU variabile de UI)
```
ROM: #D1342F rosu logo
FAST: #2E74D6 albastru logo (= accentul de UI in dark)
```
Contrast: textul principal pe fundal ramane AA in ambele teme; accentul pe alb foloseste varianta
mai inchisa (`#1F66C9`) ca text/linkul sa treaca AA.
## Tipografie
- **UI / titluri**: **IBM Plex Sans** — sans-serif cu caracter ingineresc, open-source, potrivit
pentru „software serios", parte din limbajul vizual tehnic. Fallback: `system-ui, sans-serif`.
- **Coduri / monospace**: **IBM Plex Mono** — pentru coduri RAR (REV2), VIN, numar inmatriculare,
detalii tehnice. Inlocuieste `ui-monospace/Menlo` actual cu o familie coerenta cu UI-ul.
- **Incarcare**: self-host `woff2` (subset latin + latin-ext pentru diacritice romanesti) in
`app/web/static/fonts/`, `font-display: swap`. Fara CDN extern (gateway intern, fara dependente
de retea la runtime). Pana la self-host, fallback la stiva de sistem nu strica layout-ul.
## Header & branding
- Titlul „Gateway RAR AUTOPASS" **centrat** pe header.
- Sub titlu, mic: **logo-ul ROMFAST** (`/static/romfast_logo.png`, ~28px inaltime). Decizie user
(2026-06-25, US-012b): se foloseste PNG-ul real al logo-ului (ROM rosu + FAST albastru, fundal
transparent — lizibil pe light/dark/petrol), NU wordmark-ul text. Wordmark-ul text (`by ROM FAST`
cu `ROM #D1342F` / `FAST #2E74D6`) ramane documentat ca alternativa, dar livrabila finala
foloseste imaginea.
- Controalele (comutator tema, versiune, hamburger ☰) raman la **dreapta**, fara a strica
centrarea optica a titlului (ex. grila 3 coloane: stanga goala/echilibru, centru titlu, dreapta
controale).
- Responsiv: pe mobil, wordmark-ul ramane sub titlu; controalele nu se suprapun (degrada elegant,
eventual titlu mai mic).
## Selector de tema
Inlocuieste comutatorul binar soare/luna cu un **buton ciclic** (pattern ca demoanaf.ro): un
singur buton care roteste la fiecare click prin setul de teme, cu iconita + tooltip/`aria-label`
care arata tema curenta („Tema: Light" etc.).
Ordinea ciclului: **Light → Dark → Petrol → Auto → (inapoi la Light)**.
- `Light``data-theme="light"` (azur pe alb)
- `Dark``data-theme="dark"` (azur pe inchis, comportamentul implicit actual)
- `Petrol``data-theme="petrol"` (teal pe petrol-inchis)
- `Auto` → urmeaza `prefers-color-scheme`; rezolva la Light azur sau Dark azur in functie de OS
(nu seteaza `data-theme` fix, ci il deriva la paint).
Persistenta: preferinta explicita (inclusiv „Auto") in `localStorage`, doar la click. Scriptul
anti-FOUC din `<head>` trebuie sa rezolve „Auto"→light/dark inainte de primul paint (fara blink).
Iconite: ☀ Light, ☾ Dark, ◐ Petrol, ◉ Auto. Default la prima vizita = Auto (OS-aware), ca azi.
## Componente — note de aplicare
- **Pill-uri de stare/filtru**: rotunjite (`border-radius:99px`), ca badge-ul „ROA" de pe
romfast.ro. Pill activ = fundal accent discret (`color-mix(in srgb, var(--accent) ...)`), text
pe accent. Categoriile de problema isi pastreaza registrul: Date incomplete/Eroare = `--err`,
Lipsa cod = `--warn`.
- **Butoane primare**: fundal `--accent`, text alb (neschimbat ca structura, doar culoarea noua).
- **Linkuri / sugestii**: `--accent`.
- **Focus**: `outline:2px solid var(--accent)` (deja folosit pe randuri).
- **Suprafete de stare** (banner, flash, eroare-3n): raman pe `color-mix` peste `--err/--warn/--ok`,
deci se adapteaza automat la noua paleta si la light/dark.
## Ce NU schimbam
- Mecanismul light/dark existent (anti-FOUC, persistenta `localStorage`, comutator) — il pastram,
doar reimprospatam variabilele.
- Nu introducem rosu ca accent de UI (conflict cu eroare).
- ~~Nu folosim PNG-ul logo cu efect 3D in interfata (wordmark redat ca text).~~ REVIZUIT
(decizie user 2026-06-25): logo-ul PNG real e folosit in header (US-012b). Fundal transparent +
culori proprii il fac lizibil pe toate temele; nu aplicam filtre.
- Nu adaugam un al doilea accent — sistemul ramane monocrom-accent + neutre.
## Legatura cu implementarea (PRD 5.10)
US-012 (header „by ROMFAST" + titlu centrat) si US-013 (paleta) din
`docs/prd/prd-5.10-ux-filtre-pill-paginare-mapari-meniu.md` implementeaza acest sistem. Valorile de
mai sus sunt sursa pentru variabilele din `base.html`.

View File

@@ -8,14 +8,13 @@ Endpointuri:
POST /v1/import/{id}/commit — gate HARD + enqueue randuri ok + log atestare
GET /v1/import/{id}/export-failed — CSV cu randuri esuate (needs_data/needs_mapping/needs_review)
Reguli cheie (plan §3.1-3.4, §12):
- Issue 6: scrieri bulk in tranzactie explicita BEGIN IMMEDIATE...COMMIT + executemany.
- Eng#5: already_sent lookup BATCH (IN chunk ~900), nu N+1.
- OV-3: duplicate_in_file EXCLUSIV la preview/commit. NU atinge reconcile.py/worker.
- Issue 1 (TOCTOU): commit per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
- Issue 5a: import_rows.raw_json CRIPTAT Fernet.
- Issue 5b: fuzzy coloane refoloseste mapping.normalize_for_match (DRY).
- T4/D3: drift semnatura coloane -> NU aplica orb, cere re-confirmare.
Reguli cheie:
- Scrieri bulk in tranzactie explicita BEGIN IMMEDIATE...COMMIT + executemany.
- already_sent lookup BATCH (IN chunk ~900), nu N+1.
- duplicate_in_file EXCLUSIV la preview/commit. NU atinge reconcile.py/worker.
- TOCTOU: commit per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
- import_rows.raw_json CRIPTAT Fernet.
- Drift semnatura coloane -> NU aplica orb, cere re-confirmare.
"""
from __future__ import annotations
@@ -60,7 +59,7 @@ router = APIRouter(prefix="/v1/import", tags=["import"])
# Marimea maxima a unui chunk pentru IN(...) SQLite (limite SQLite ~999)
_IN_CHUNK = 900
# Campuri canonice si sinonimele lor pentru sugestie fuzzy coloane (Issue 5b/Eng#4)
# Campuri canonice si sinonimele lor pentru sugestie fuzzy coloane
_CANONICAL_SYNONYMS: dict[str, list[str]] = {
"vin": ["VIN", "Serie sasiu", "Sasiu", "Serie", "Numar sasiu", "Nr sasiu", "Chassis"],
"nr_inmatriculare": ["Nr inmatriculare", "Numar inmatriculare", "Numar auto", "Nr auto", "Numar", "Nr"],
@@ -93,7 +92,7 @@ def _fuzzy_suggest_column(
) -> list[dict]:
"""Sugereaza campuri canonice pentru o coloana din fisier.
Refoloseste normalize_for_match + rapidfuzz.fuzz.token_sort_ratio (Issue 5b/Eng#4).
Refoloseste normalize_for_match + rapidfuzz.fuzz.token_sort_ratio.
Intoarce [{camp_canonic, score}] sortat descrescator.
"""
from rapidfuzz import fuzz, process
@@ -140,10 +139,10 @@ def _resolve_row_for_preview(
errors: lista erori validare
flags: motive needs_review
`override` (3.6, Approach B): patch CANONIC editat in preview, aplicat ULTIMUL
peste valorile mapate (dupa `json_mapare` si canonicalizare). Permite corectarea
unei valori sau completarea unui camp a carui coloana LIPSESTE din fisier, fara
sa atinga `raw_json`/idempotency.
`override`: patch CANONIC editat in preview, aplicat ULTIMUL peste valorile
mapate (dupa `json_mapare` si canonicalizare). Permite corectarea unei valori
sau completarea unui camp a carui coloana LIPSESTE din fisier, fara sa atinga
`raw_json`/idempotency.
"""
# Aplica maparea de coloane
mapped: dict[str, Any] = {}
@@ -151,7 +150,7 @@ def _resolve_row_for_preview(
if col_fisier in raw_row and camp_canonic:
mapped[camp_canonic] = raw_row[col_fisier]
# Detectie coloane cu formule (Issue 3) — nu blocheaza, dar adauga flag
# Detectie coloane cu formule — nu blocheaza, dar adauga flag
formula_flag: list[str] = []
for col_fisier, camp_canonic in json_mapare.items():
if col_fisier in formula_columns:
@@ -186,7 +185,7 @@ def _resolve_row_for_preview(
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
# Canonicalizare (T9): normalizeaza VIN/nr/odometru
# Canonicalizare: normalizeaza VIN/nr/odometru
canon = canonicalize_row(mapped)
mapped.update({
"vin": canon["vin"],
@@ -194,7 +193,7 @@ def _resolve_row_for_preview(
"odometru_final": canon["odometru_final"],
})
# Override editat in preview (3.6) — aplicat ULTIMUL, peste valorile mapate +
# Override editat in preview — aplicat ULTIMUL, peste valorile mapate +
# canonicalizate. Valorile din override sunt deja canonice (vezi _merge_override).
if override:
mapped.update(override)
@@ -230,7 +229,7 @@ def _resolve_row_for_preview(
"flags": all_flags,
}
# auto_send gate (T6/OV-1)
# auto_send gate
if has_no_auto_send(resolved, mapping_meta):
return {
"resolved_status": "needs_mapping",
@@ -261,8 +260,8 @@ def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any]) ->
return build_key(account_id, canon)
# Campuri de continut editabile in preview (3.6). Operatia/codul RAR NU se editeaza
# aici (raman in panoul de mapare) — vezi Non-Goals din PRD 3.6.
# Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza
# aici (raman in panoul de mapare).
EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final")
@@ -350,7 +349,7 @@ def apply_row_override(
def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, dict]:
"""Cauta cheile de idempotenta in submissions (batch, nu N+1 — Eng#5).
"""Cauta cheile de idempotenta in submissions (batch, nu N+1).
Intoarce {idempotency_key: {id, id_prezentare, created_at}} pentru cheile gasite.
"""
@@ -371,7 +370,7 @@ def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, di
"id_prezentare": r["id_prezentare"],
"created_at": r["created_at"],
}
# Dual-lookup pentru chei legacy (OV-2: chei vechi cu account_id=None)
# Dual-lookup pentru chei legacy (chei vechi cu account_id=None)
legacy_keys_needed = [k for k in chunk if k not in found]
if legacy_keys_needed:
lph = ",".join("?" * len(legacy_keys_needed))
@@ -403,8 +402,7 @@ async def upload_import(
"""Upload fisier xlsx/csv -> staging in import_batches/import_rows.
Nu trimite nimic la RAR. Intoarce {import_id, columns, sample_rows, sheets?}.
PII (raw_json) criptat Fernet la rest (Issue 5a).
Scrieri bulk in tranzactie explicita (Issue 6).
PII (raw_json) criptat Fernet la rest. Scrieri bulk in tranzactie explicita.
"""
acct = account_or_default(account_id)
data = await file.read()
@@ -468,7 +466,7 @@ async def upload_import(
try:
sig = _signature(parsed.columns)
# Issue 6: tranzactie explicita BEGIN IMMEDIATE + executemany
# Tranzactie explicita BEGIN IMMEDIATE + executemany
conn.execute("BEGIN IMMEDIATE")
try:
# Insert import_batches
@@ -482,7 +480,7 @@ async def upload_import(
# Insert import_rows bulk (executemany) cu PII criptat
row_params = []
for i, row_dict in enumerate(parsed.rows):
raw_json_enc = encrypt_creds(row_dict) # Criptat Fernet (Issue 5a)
raw_json_enc = encrypt_creds(row_dict) # Criptat Fernet
row_params.append((batch_id, i, raw_json_enc, "pending", None))
conn.executemany(
@@ -506,11 +504,8 @@ async def upload_import(
# Sample rows (primele 3, fara PII)
sample = parsed.rows[:3]
# Persistam metadata parsedata (coercion_flags, date_col_format, formula_columns)
# in import_batches pentru refolosire la preview (stocam ca JSON in 'status' nu e OK,
# ci ca metadate suplimentare — le stocam intr-un rand separat sau returnam direct)
# Solutie: le returnam in raspuns; preview-ul le va recalcula din raw_json deja stocat
# SAU le stocam ca un camp extra. Cel mai simplu: stocam coloanele in batch.
# Metadata parsata (coercion_flags etc.) se intoarce in raspuns; preview-ul
# o recalculeaza din raw_json deja stocat.
conn.execute(
"UPDATE import_batches SET ok=?, needs_review=? WHERE id=?",
(0, len(parsed.coercion_flags), batch_id),
@@ -532,7 +527,7 @@ async def upload_import(
result["column_mapping"] = json.loads(existing_mapping["json_mapare"])
result["format_data"] = existing_mapping["format_data"]
else:
# Sugestii fuzzy per coloana (Issue 5b: refoloseste normalize_for_match)
# Sugestii fuzzy per coloana
suggestions: dict[str, list[dict]] = {}
for col in parsed.columns:
sugg = _fuzzy_suggest_column(col, limit=3)
@@ -676,7 +671,7 @@ def save_column_mapping(
# --------------------------------------------------------------------------- #
# GET /v1/import/{id}/preview — 6 stari per rand (T2 + T11) #
# GET /v1/import/{id}/preview — 6 stari per rand #
# --------------------------------------------------------------------------- #
@router.get("/{import_id}/preview")
@@ -686,8 +681,8 @@ def preview_import(
) -> dict:
"""Preview 6 stari per rand: ok/needs_mapping/needs_data/needs_review/already_sent/duplicate_in_file.
Nu enqueue-aza nimic. Already_sent = lookup batch (Eng#5). Duplicate_in_file = intra-batch
collision (OV-3: EXCLUSIV aici, NU in reconcile.py/worker).
Nu enqueue-aza nimic. Already_sent = lookup batch. Duplicate_in_file = intra-batch
collision (EXCLUSIV aici, NU in reconcile.py/worker).
"""
acct = account_or_default(account_id)
conn = get_connection()
@@ -708,7 +703,7 @@ def preview_import(
if not raw_rows_db:
return {"rows": [], "summary": {}}
# Decripteaza si reconstruieste randurile + override-urile editate (3.6)
# Decripteaza si reconstruieste randurile + override-urile editate
rows: list[dict] = []
overrides: list[dict] = []
for r in raw_rows_db:
@@ -747,22 +742,18 @@ def preview_import(
json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"])
format_data = mapping_row["format_data"]
# Incarca maparea de operatii o singura data (Eng#5: load_mapping o singura data)
# Incarca maparea de operatii o singura data
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
# Validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct)
# Reconstruieste parsed info (coercion_flags si date_col_format) din datele stocate
# Nota: import_rows stocheaza raw_json dupa coercion (din parse_file)
# Recalculam flags din valorile stocate (coercion_flags nu e stocat separat)
# Vom folosi o detectie simpla: VIN-uri care par numerice si odometru float
# Recalculam coercion_flags din valorile stocate (nu sunt persistate separat):
# detectie simpla de VIN numeric.
coercion_flags_map: dict[int, list[str]] = {}
# Detectam din valorile stocate
for i, row_dict in enumerate(rows):
flags = []
# Detectam VIN numeric: daca valoarea a fost stocata si arata ca numar
for col_f, camp_c in json_mapare.items():
if camp_c == "vin":
vin_val = row_dict.get(col_f)
@@ -830,11 +821,11 @@ def preview_import(
"idempotency_key": key,
})
# Already_sent: batch lookup (Eng#5 — nu N+1)
# Already_sent: batch lookup (nu N+1)
unique_keys = list(set(keys_for_lookup))
already_sent_map = _already_sent_lookup(conn, account_id, unique_keys)
# Duplicate_in_file (OV-3): detectie coliziuni intra-batch
# Duplicate_in_file: detectie coliziuni intra-batch.
# Grupam pe cheie de idempotenta: >1 rand cu aceeasi cheie = duplicate
key_to_indices: dict[str, list[int]] = {}
for row in preview_rows:
@@ -857,7 +848,7 @@ def preview_import(
row["already_sent_info"] = sent_info
continue
# Duplicate_in_file (OV-3): >1 rand cu aceeasi cheie in ACELASI fisier
# Duplicate_in_file: >1 rand cu aceeasi cheie in ACELASI fisier
indices_with_same_key = key_to_indices.get(k, [])
if len(indices_with_same_key) > 1 and row["resolved_status"] in ("ok", "needs_review", "needs_data"):
others = [idx for idx in indices_with_same_key if idx != row["row_index"]]
@@ -911,7 +902,7 @@ def preview_import(
# --------------------------------------------------------------------------- #
# POST /v1/import/{id}/commit — gate HARD + enqueue + log atestare (T5+T12) #
# POST /v1/import/{id}/commit — gate HARD + enqueue + log atestare #
# --------------------------------------------------------------------------- #
class CommitIn(BaseModel):
@@ -929,9 +920,9 @@ def commit_import(
req: CommitIn,
account_id: int = Depends(resolve_account_id),
) -> dict:
"""Gate HARD confirmare + enqueue randuri ok + log atestare (T5+T12).
"""Gate HARD confirmare + enqueue randuri ok + log atestare.
TOCTOU (Issue 1): INSERT per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
TOCTOU: INSERT per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
Randuri colidante = reclasificate already_sent in rezultatul commit-ului.
rows_hash + n_confirmed acopera DOAR randurile efectiv puse in coada.
"""
@@ -981,7 +972,7 @@ def commit_import(
elif r["resolved_status"] == "needs_review":
review_indices.add(r["row_index"])
# needs_review bifate explicit (Voce#1 — atestare pe valori)
# needs_review bifate explicit (atestare pe valori)
confirmed_review = [idx for idx in req.reviewed_rows if idx in review_indices]
for idx in confirmed_review:
# Gaseste randul needs_review si il adauga la ok_rows
@@ -1040,7 +1031,7 @@ def commit_import(
# Incarca maparea de operatii
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
# Validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct)
@@ -1049,10 +1040,9 @@ def commit_import(
toctou_collisions: list[int] = []
rows_for_hash: list[str] = []
# Enqueue in tranzactie explicita (Issue 6)
# Enqueue in tranzactie explicita
conn.execute("BEGIN IMMEDIATE")
try:
# purge_after pentru submissions noi (T16)
purge_after_sql = "datetime('now', '+90 days')"
for ok_row in ok_rows:
@@ -1100,7 +1090,7 @@ def commit_import(
"odometru_final": canon["odometru_final"],
})
# Override editat in preview (3.6) — aplicat ULTIMUL, ca in resolver.
# Override editat in preview — aplicat ULTIMUL, ca in resolver.
override = ok_row.get("override") or {}
if override:
mapped.update(override)
@@ -1127,7 +1117,7 @@ def commit_import(
payload_json = json.dumps(mapped, ensure_ascii=False)
# INSERT ON CONFLICT DO NOTHING (TOCTOU — Issue 1)
# INSERT ON CONFLICT DO NOTHING (TOCTOU)
cur = conn.execute(
"INSERT OR IGNORE INTO submissions "
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after) "
@@ -1140,7 +1130,6 @@ def commit_import(
toctou_collisions.append(row_index)
else:
sub_id = cur.lastrowid
# US-010: telemetrie pentru itemii rezolvati prin regula text.
_emite_text_rule_hits(conn, acct, int(sub_id), resolved)
enqueued.append({
"submission_id": sub_id,
@@ -1155,7 +1144,7 @@ def commit_import(
n_enqueued = len(enqueued)
# Log atestare (Voce#9): rows_hash + n_confirmed acopera DOAR randurile puse in coada
# Log atestare: rows_hash + n_confirmed acopera DOAR randurile puse in coada
rows_hash = hashlib.sha256(
json.dumps(sorted(rows_for_hash), ensure_ascii=False).encode("utf-8")
).hexdigest() if rows_for_hash else ""
@@ -1185,7 +1174,7 @@ def commit_import(
# --------------------------------------------------------------------------- #
# POST /v1/import/{id}/rand/{row_index}/editeaza — editare celule preview (3.6) #
# POST /v1/import/{id}/rand/{row_index}/editeaza — editare celule preview #
# --------------------------------------------------------------------------- #
class RandEditIn(BaseModel):
@@ -1205,7 +1194,7 @@ def editeaza_rand(
req: RandEditIn,
account_id: int = Depends(resolve_account_id),
) -> dict:
"""Persista editarea unui rand de preview (mutatie pura — Approach B, 3.6).
"""Persista editarea unui rand de preview (mutatie pura).
NU recalculeaza statusul si NU atinge `submissions`; preview-ul rederiva statusul
prin `_resolve_row_for_preview` cu override aplicat ultimul.
@@ -1225,7 +1214,7 @@ def editeaza_rand(
# --------------------------------------------------------------------------- #
# GET /v1/import/{id}/export-failed — CSV randuri esuate (T8) #
# GET /v1/import/{id}/export-failed — CSV randuri esuate #
# --------------------------------------------------------------------------- #
_EXPORT_FAILED_COLUMNS = [

View File

@@ -1,4 +1,4 @@
"""Router integrare US-001 — endpoint-uri de integrare externe.
"""Router integrare — endpoint-uri de integrare externe.
Endpointuri:
GET /v1/ping — readiness check per cont (autentificat sau dev fallback)

View File

@@ -1,12 +1,10 @@
"""API v1 — suprafata gateway (schelet).
"""API v1 — suprafata gateway.
Endpointuri din plan.md sect. 4. In schelet:
Endpointuri:
- POST /v1/prezentari: enqueue cu idempotenta (dedup pe idempotency_key UNIQUE).
- GET /v1/prezentari, /v1/prezentari/{id}: monitorizare coada.
- GET /v1/nomenclator: cache local.
- GET /v1/mapari: listare mapari cont.
Validarea completa (T3), maparea op->cod, auth API-key, redactarea creds in
middleware (CORE) si exportul CSV vin ulterior — marcate TODO unde lipsesc.
"""
from __future__ import annotations
@@ -79,7 +77,7 @@ def _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, tex
def _erori_nemapate(unmapped: list[dict]) -> list[dict]:
"""Coduri nemapate imbogatite cu 3 niveluri (COD_NEMAPAT), pentru raspuns onest."""
"""Coduri nemapate imbogatite cu 3 niveluri (COD_NEMAPAT)."""
return [
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} necunoscut/fara mapare RAR")}
for u in unmapped
@@ -87,7 +85,7 @@ def _erori_nemapate(unmapped: list[dict]) -> list[dict]:
def _motiv_clasificare(cl: dict) -> str | None:
"""Rezumat uman pe o linie pentru un rezultat de clasificare (PRD 5.7).
"""Rezumat uman pe o linie pentru un rezultat de clasificare.
None cand status='queued'. Acopera toate ramurile de blocaj: erori de continut
(needs_data), coduri nemapate (needs_mapping) si auto_send oprit (needs_mapping).
@@ -107,7 +105,7 @@ def _motiv_clasificare(cl: dict) -> str | None:
def _rezultat_enqueue(submission_id: int | None, cl: dict, **extra) -> SubmissionResult:
"""SubmissionResult onest dintr-un rezultat de clasificare (PRD 5.7).
"""SubmissionResult onest dintr-un rezultat de clasificare.
Populeaza erori (validare continut), nemapate (coduri fara mapare) si motiv (uman)
pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None.
@@ -141,42 +139,40 @@ def create_prezentari(
) -> PrezentariResponse:
"""Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission.
Validarea de continut (T3, app.validation) ruleaza inainte de enqueue:
esecurile NU resping cererea, ci enqueue-aza cu status `needs_data` + motiv
(plan.md sect. 3). JSON malformat -> 422 din Pydantic (validare de shape).
Validarea de continut (app.validation) ruleaza inainte de enqueue: esecurile NU
resping cererea, ci enqueue-aza cu status `needs_data` + motiv. JSON malformat ->
422 din Pydantic (validare de shape).
account_id vine din cheia API (resolve_account_id): cont real cu cheie,
implicit id=1 in dev fara cheie, 401 fara cheie valida in prod.
Nota: rar_credentials NU se persista (zero-storage) — worker-ul le va primi
pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea.
Cand rar_credentials lipseste, submission-ul intra fara creds efemere: worker-ul
cade pe creds-urile durabile ale contului (`accounts.rar_creds_enc`).
"""
acct = account_or_default(account_id)
# Creds RAR efemere: criptate si lipite de fiecare submission nou pana la
# primul login reusit pentru cont (worker le sterge atunci). Zero-storage at
# rest — niciodata in clar in DB/loguri (plan sect. 5). Optional: cand lipsesc,
# rest — niciodata in clar in DB/loguri. Optional: cand lipsesc,
# creds_enc=NULL si worker-ul foloseste creds-urile durabile ale contului.
creds_enc = encrypt_creds(req.rar_credentials.model_dump()) if req.rar_credentials else None
conn = get_connection()
results: list[SubmissionResult] = []
try:
# T6/OV-1: load_mapping_meta include auto_send per op (gate pentru coduri noi).
# load_mapping_meta include auto_send per op (gate pentru coduri noi).
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat.
# valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot).
valid_codes = load_nomenclator_codes(conn) or None
# Reguli text incarcate o data per cerere (seam partajat cu dry-run, invariant 5.2).
# Reguli text incarcate o data per cerere (seam partajat cu dry-run).
text_rules = load_text_rules(conn, acct)
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
for prez in req.prezentari:
content = prez.model_dump()
# T9/OV-2: canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).
# canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).
# build_key aplica account_or_default(account_id) inainte de hash:
# None si 1 colapseaza la aceeasi cheie (canal API + canal import).
canon = canonicalize_row(content)
key = build_key(account_id, canon)
# Aplica normalizarea si in content (odometru canonicalizat inainte de validare, §3.4bis)
# Aplica normalizarea si in content (odometru canonicalizat inainte de validare)
content.update({
"vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"],
@@ -187,7 +183,7 @@ def create_prezentari(
(key,),
).fetchone()
if existing:
# US-012: un rand `error` (ex. creds RAR gresite) NU mai blocheaza tacit
# Un rand `error` (ex. creds RAR gresite) NU mai blocheaza tacit
# retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam
# creds + reset), printr-un UPDATE compare-and-swap pe status='error'.
if existing["status"] == "error":
@@ -205,17 +201,16 @@ def create_prezentari(
cl["rar_error"], creds_enc, existing["id"]),
)
if cur.rowcount == 1:
# Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc,
# decizie #17) — ambele canale converg pe parola corectata.
# Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc)
# — ambele canale converg pe parola corectata.
if req.rar_credentials is not None:
conn.execute(
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
(encrypt_creds(req.rar_credentials.model_dump()), acct),
)
# US-010: telemetrie pentru itemii rezolvati prin regula text.
_emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"])
# Raspuns onest si la reactivare (PRD 5.7): daca re-clasificarea
# cade pe needs_data/needs_mapping, expune motivul (nu doar status).
# Raspuns onest si la reactivare: daca re-clasificarea cade pe
# needs_data/needs_mapping, expune motivul (nu doar status).
results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True))
continue
# Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE
@@ -234,7 +229,7 @@ def create_prezentari(
)
continue
# Helper pur partajat cu dry-run (PRD 5.2): reproduce EXACT clasificarea
# Helper pur partajat cu dry-run: reproduce EXACT clasificarea
# (canonicalize + mapare op->cod + validare + auto_send gate).
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
if cl["blocked_error"]:
@@ -247,13 +242,12 @@ def create_prezentari(
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc),
)
sub_id = int(cur.lastrowid)
# US-010: telemetrie pentru itemii rezolvati prin regula text.
_emite_text_rule_hits(conn, acct, sub_id, cl["resolved"])
# Raspuns onest (PRD 5.7): pe needs_data/needs_mapping expune erori/nemapate/motiv.
# Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv.
results.append(_rezultat_enqueue(sub_id, cl))
# US-004: audit cerere API per cont. Doar metadate (count + distributie status),
# NICIUN camp de payload PII integral. Reuse conn (T4 — fara contentie WAL).
# Audit cerere API per cont. Doar metadate (count + distributie status),
# NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL).
dist: dict[str, int] = {}
for r in results:
if r.reactivated:
@@ -284,7 +278,7 @@ def valideaza_prezentari(
Intoarce pentru fiecare prezentare: verdictul (status_estimat), erorile de
continut si codurile nemapate — exact ce ar obtine trimiterea reala pe acelasi
payload + aceeasi mapare de cont. rar_credentials ignorat complet (PRD 5.2).
payload + aceeasi mapare de cont. rar_credentials ignorat complet.
"""
acct = account_or_default(account_id)
conn = get_connection()
@@ -301,7 +295,7 @@ def valideaza_prezentari(
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
if res["blocked_error"]:
res = {**res, "status": "error"}
# US-003: imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
# Imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
nemapate = [
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} fara mapare RAR")}
for u in res["unmapped"]
@@ -329,7 +323,7 @@ def list_prezentari(
try:
scope_sql, scope_params = account_scope_clause(account_id)
# payload_json e plaintext (vezi submissions.payload_json); il citim doar ca
# sa derivam campurile afisabile prin helper-ul partajat (US-003, DRY), nu il expunem.
# sa derivam campurile afisabile prin helper-ul partajat, nu il expunem.
cols = (
"id, status, id_prezentare, rar_status_code, retry_count, "
"created_at, updated_at, payload_json"
@@ -357,13 +351,13 @@ def list_prezentari(
conn.close()
# Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita (B4).
# Exclude: rar_creds_enc, payload_json, idempotency_key, rar_error, sending_since.
# Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita.
# Exclude: rar_creds_enc, payload_json, idempotency_key, sending_since.
_PREZENTARE_FIELDS = frozenset({
"id", "status", "id_prezentare", "rar_status_code", "retry_count",
"next_attempt_at", "created_at", "updated_at", "account_id",
"batch_id", "row_index", "purge_after",
# T9: rar_error e SIGUR de expus — contine doar coduri/mesaje de validare RAR si
# rar_error e SIGUR de expus — contine doar coduri/mesaje de validare RAR si
# erori din catalog (niciodata creds, ex. RAR_CREDS_INVALIDE poarta doar cauza
# "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API.
"rar_error",
@@ -383,7 +377,7 @@ def get_prezentare(
[submission_id] + scope_params,
).fetchone()
if not row:
# B3: acelasi mesaj indiferent daca randul exista dar apartine altui cont
# Acelasi mesaj indiferent daca randul exista dar apartine altui cont
# sau nu exista deloc — nu confirmam existenta.
raise HTTPException(status_code=404, detail="submission inexistent")
row_dict = dict(row)
@@ -397,11 +391,11 @@ def delete_prezentare(
submission_id: int,
account_id: int = Depends(resolve_account_id),
) -> dict:
"""Sterge o trimitere blocata a contului cheii API (US-010).
"""Sterge o trimitere blocata a contului cheii API.
Raspuns 200 + body JSON (NU 204 — clienti VFP fac string-parse). Scope evaluat
INAINTEA starii (decizie /autoplan #20): cross-account / inexistent -> 404 (acelasi
mesaj, B3); own-account `sent`/`sending` -> 409 (conflict de stare).
INAINTEA starii: cross-account / inexistent -> 404 (acelasi mesaj);
own-account `sent`/`sending` -> 409 (conflict de stare).
"""
conn = get_connection()
try:
@@ -424,10 +418,10 @@ def repune_prezentare(
submission_id: int,
account_id: int = Depends(resolve_account_id),
) -> dict:
"""Re-pune in coada o trimitere blocata a contului cheii API (US-010).
"""Re-pune in coada o trimitere blocata a contului cheii API.
`error -> queued` (peste helper US-009), re-ruleaza classify. Acelasi oracol de
scope/stare ca DELETE (404 cross-account/inexistent, 409 sent/sending).
`error -> queued`, re-ruleaza classify. Acelasi oracol de scope/stare ca DELETE
(404 cross-account/inexistent, 409 sent/sending).
"""
conn = get_connection()
try:
@@ -478,8 +472,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a
"""Randuri audit filtrate pe cont + data(updated_at) in [from, to].
account_id = contul cheii API (scope obligatoriu — PII in CSV). Randuri cu
account_id IS NULL apartin contului 1 (legacy/OV-2). payload_json e text in
schelet; b64_image NU intra in CSV.
account_id IS NULL apartin contului 1. b64_image NU intra in CSV.
"""
scope_sql, scope_params = account_scope_clause(account_id)
sql = (
@@ -514,7 +507,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a
"submission_id": r["id"],
"status": r["status"],
"id_prezentare": r["id_prezentare"] or "",
# NULL→cont 1 (OV-2): coloana reflecta invariantul de scope, nu "" ambiguu.
# NULL→cont 1: coloana reflecta invariantul de scope, nu "" ambiguu.
"account_id": account_or_default(r["account_id"]),
"vin": p.get("vin") or "",
"nr_inmatriculare": p.get("nr_inmatriculare") or "",
@@ -539,7 +532,7 @@ def audit_export(
pe data(updated_at). `status` implicit `sent` (ce a ajuns efectiv la RAR);
`status=all` exporta toata coada contului. Leaga retinerea 90 zile prin coloana
`purge_after` (plan.md sect. 4 + 8). b64_image nu se exporta.
`purge_after`. b64_image nu se exporta.
"""
conn = get_connection()
try:
@@ -568,7 +561,7 @@ def get_mapari(
"""Maparile operatie->cod ale contului curent.
Parametrul `account_id` din query e pastrat pentru compatibilitate, dar contul
efectiv vine MEREU din cheia API (TD-3.2). Daca e prezent si difera -> 400.
efectiv vine MEREU din cheia API. Daca e prezent si difera -> 400.
"""
if account_id is not None and account_id != key_account:
raise HTTPException(
@@ -635,7 +628,7 @@ def create_mapare(
class RarCredsIn(BaseModel):
"""Creds RAR durabile per-cont (D4). Stocate criptate (Fernet) in accounts.rar_creds_enc."""
"""Creds RAR durabile per-cont. Stocate criptate (Fernet) in accounts.rar_creds_enc."""
email: str = Field(..., min_length=1)
password: str = Field(..., min_length=1, repr=False)
@@ -646,7 +639,7 @@ def set_rar_creds(
req: RarCredsIn,
account_id: int = Depends(resolve_account_id),
) -> dict:
"""Seteaza creds RAR durabile per-cont (D4/T1).
"""Seteaza creds RAR durabile per-cont.
Criptate Fernet in accounts.rar_creds_enc. Worker-ul le foloseste ca fallback
cand submission-ul nu mai are creds (canal web fara re-pusher, restart worker).

View File

@@ -112,7 +112,7 @@ def _extract_key(x_api_key: str | None, authorization: str | None) -> str | None
def _log_auth_esuat(request: Request | None, plaintext: str | None, motiv: str) -> None:
"""Eveniment de jurnal pentru un esec de auth (US-004): IP + prefix cheie, NU cheia.
"""Eveniment de jurnal pentru un esec de auth: IP + prefix cheie, NU cheia.
Best-effort (log_event inghite erorile). Import local: evita cuplarea la import-time
(observ -> db; auth -> db) si pastreaza auth.py importabil din CLI fara efecte.
@@ -142,7 +142,7 @@ def resolve_account_id(
- cheie invalida (prezenta) -> 401 (mereu, indiferent de flag)
- fara cheie + flag off -> cont implicit (id=1), back-compat
- fara cheie + flag on -> 401
Esecurile de auth (401) emit `api_auth_esuat` cu IP + prefix cheie (US-004).
Esecurile de auth (401) emit `api_auth_esuat` cu IP + prefix cheie.
"""
settings = get_settings()
plaintext = _extract_key(x_api_key, authorization)

View File

@@ -1,8 +1,8 @@
"""Configurare gateway. Env vars (prefix AUTOPASS_) + valori implicite.
NU stocheaza parole RAR. Credentialele RAR vin per-cerere de la ROAAUTO
(vezi plan.md sect. 5). Helper-ul `load_test_credentials` citeste blocul
<test> din settings.xml DOAR pentru dev local / probe pe mediul de test.
NU stocheaza parole RAR. Credentialele RAR vin per-cerere de la ROAAUTO.
Helper-ul `load_test_credentials` citeste blocul <test> din settings.xml DOAR
pentru dev local / probe pe mediul de test.
"""
from __future__ import annotations
@@ -22,22 +22,21 @@ class Settings(BaseSettings):
# --- Bază de date ---
db_path: Path = ROOT / "data" / "autopass.db"
# --- Observabilitate / jurnal aplicatie (PRD 5.6) ---
# --- Observabilitate / jurnal aplicatie ---
# Nivel minim al evenimentelor scrise in app_events + log text. Sub el, evenimentul
# e ignorat (best-effort). DEBUG|INFO|WARNING|ERROR|CRITICAL.
log_level: str = "INFO"
# Retentie jurnal (app_events) — aliniat cu submissions/import_batches (decizie §5).
log_retention_days: int = 90
# Director pentru log-ul text rotativ (RotatingFileHandler in aplicatie, decizie §5).
# Director pentru log-ul text rotativ (RotatingFileHandler in aplicatie).
# Fisier per-proces (app-api.log / app-worker.log) — rotatia nu e multiproces-safe.
log_dir: Path = ROOT / ".run"
log_file_max_bytes: int = 5_000_000
log_file_backup_count: int = 5
# Retentie randuri blocate (error/needs_data/needs_mapping). Mai scurt decat 90z
# ale `sent` — un blocat n-are valoare de audit (decizie §5).
# ale `sent` — un blocat n-are valoare de audit.
blocked_retention_days: int = 30
# --- Securitate (CORE) ---
# --- Securitate ---
# Enforcement auth API-key pe /v1/* protejat. False (dev/test): fara cheie ->
# cont implicit id=1. True (prod): fara cheie valida -> 401. O cheie PREZENTA
# dar invalida da 401 indiferent de flag.
@@ -49,29 +48,28 @@ class Settings(BaseSettings):
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
creds_key: str | None = None
# --- Sesiuni web (US-002, PRD 3.3) ---
# --- Sesiuni web ---
# Secret semnat cookie sesiune. None -> efemer la fiecare restart (dev ok;
# in prod seteaza persistent ca si creds_key, altfel cookieurile se invalideaza
# la restart). Genereaza: python -c "import secrets; print(secrets.token_hex(32))"
session_secret: str | None = None
# True (IMPLICIT, sigur pentru prod): rutele web fara sesiune -> redirect /login;
# CSRF enforce. Pentru dev rapid pe contul implicit id=1 (back-compat C12/§5 Q5),
# CSRF enforce. Pentru dev rapid pe contul implicit id=1,
# seteaza explicit AUTOPASS_WEB_AUTH_REQUIRED=false.
web_auth_required: bool = True
# True (prod, in spatele Cloudflare Tunnel TLS): cookie cu Secure flag (C4).
# True (prod, in spatele Cloudflare Tunnel TLS): cookie cu Secure flag.
# False (dev): cookie fara Secure, functioneaza pe HTTP.
session_https_only: bool = False
# --- Notificare email admin la signup (US-012, PRD 3.3b) ---
# Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP);
# follow-up cand exista SMTP real configurat in .env.
# --- Notificare email admin la signup ---
# Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP).
smtp_host: str | None = None
smtp_port: int = 587
smtp_user: str | None = None
smtp_password: str | None = None
smtp_from: str | None = None
# --- Rate-limit signup + login (US-009, PRD 3.3 C5) ---
# --- Rate-limit signup + login ---
# Max cereri POST /signup per IP in fereastra de timp (in-proces, fara dependinta noua).
signup_rate_max: int = 5
signup_rate_window_s: int = 3600
@@ -83,25 +81,23 @@ class Settings(BaseSettings):
rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass"
rar_base_url_prod: str = "https://apps.rarom.ro/rar-autopass"
# WAF-ul RAR da 403 fara User-Agent de browser (confirmat live, vezi
# docs/api-rar-contract.md). Toate apelurile httpx il trimit.
# WAF-ul RAR da 403 fara User-Agent de browser. Toate apelurile httpx il trimit.
http_user_agent: str = "Mozilla/5.0"
http_timeout_s: float = 30.0
# --- Worker ---
worker_poll_interval_s: float = 5.0
worker_heartbeat_stale_s: int = 30 # /healthz considera worker-ul mort peste atat
# In schelet send-ul e DEZACTIVAT (nu trimite la RAR). Activeaza-l explicit
# pentru proba end-to-end. Reconcilierea/retry-ul complet = T2.
# Send DEZACTIVAT implicit (nu trimite la RAR). Activeaza-l explicit pentru
# proba end-to-end.
worker_send_enabled: bool = False
# Dev: foloseste creds <test> din settings.xml pt login worker. In productie
# creds vin per-cerere de la ROAAUTO (T2) — lasa False.
# creds vin per-cerere de la ROAAUTO — lasa False.
worker_use_test_creds: bool = False
# T2 — recuperare orfane + retry/backoff:
worker_sending_lease_s: int = 120 # rand 'sending' mai vechi de atat = orfan (worker mort mid-POST)
worker_retry_base_s: int = 5 # backoff = base * 2^retry (plafonat la max)
worker_retry_max_s: int = 300
worker_max_retries: int = 8 # peste atat -> error + banner (pana persistenta)
worker_max_retries: int = 8 # peste atat -> error + banner
@property
def rar_base_url(self) -> str:

View File

@@ -61,13 +61,13 @@ def _migrate(conn: sqlite3.Connection) -> None:
if "rar_creds_enc" not in acc_cols:
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_enc TEXT")
if "active" not in acc_cols:
# Conturi existente raman active (default 1). Lifecycle consumat de 3.3.
# Conturi existente raman active (default 1).
conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1")
acc_cols.add("active")
if "status" not in acc_cols:
# Stare de ciclu de viata (5.5). Defensiv idempotent (ca is_admin in 3.3b).
# Default 'active' (trece CHECK pe randurile existente), apoi derivam din `active`:
# active=0 -> 'pending'. Invariant: active=1 <=> status='active'.
# Stare de ciclu de viata. Default 'active' (trece CHECK pe randurile existente),
# apoi derivam din `active`: active=0 -> 'pending'.
# Invariant: active=1 <=> status='active'.
conn.execute(
"ALTER TABLE accounts ADD COLUMN status TEXT NOT NULL DEFAULT 'active' "
"CHECK (status IN ('pending','active','blocked','archived','deleted'))"
@@ -97,9 +97,7 @@ def _migrate(conn: sqlite3.Connection) -> None:
if "email_verified" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0")
# Coloana import_rows.override_json (3.6, Approach B): patch canonic editat in
# preview, criptat Fernet. Defensiv idempotent (ca is_admin in 3.3b) — DB create
# inainte de 3.6 nu au coloana.
# Coloana import_rows.override_json: patch canonic editat in preview, criptat Fernet.
irows_tbl = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='import_rows'"
).fetchone()
@@ -151,7 +149,7 @@ def queue_depth(conn: sqlite3.Connection) -> int:
return int(row["n"]) if row else 0
# --- Jurnal de aplicatie (app_events, PRD 5.6 US-003) ---
# --- Jurnal de aplicatie (app_events) ---
def insert_app_event(
conn: sqlite3.Connection,

View File

@@ -1,4 +1,4 @@
"""Helper notificare email admin la signup (US-012, PRD 3.3b).
"""Helper notificare email admin la signup.
Livrare DEGRADATA: daca smtp_host nu e configurat, functia e no-op (log doar).
Orice eroare SMTP e prinsa si logata — signup-ul NU e blocat niciodata.

View File

@@ -1,4 +1,4 @@
"""Catalog central de erori AutoPass (PRD 5.4).
"""Catalog central de erori AutoPass.
Singura sursa de adevar care mapeaza fiecare cod de eroare la (problema, fix),
cu un helper care construieste obiectul de eroare pe 3 niveluri:

View File

@@ -1,24 +1,18 @@
"""Cheie de idempotenta = hash de continut canonic.
RAR nu are camp nr. comanda si accepta duplicate -> dedup-ul e in sarcina noastra
(plan.md sect. 14). Hash stabil peste o reprezentare canonica a prezentarii.
RAR nu are camp nr. comanda si accepta duplicate -> dedup-ul e in sarcina noastra.
Hash stabil peste o reprezentare canonica a prezentarii.
Treapta 2 (T9 + OV-2): extrage canonicalize_row + build_key ca helpere publice
partajate intre canalul API si canalul import.
canonicalize_row + build_key sunt helpere publice partajate intre canalul API si
canalul import:
- canonicalize_row: normeaza VIN/nr/odometru (strip ".0" Excel coercion) INAINTE
de validare (§3.4bis) si INAINTE de cheie.
de validare si INAINTE de cheie.
- build_key: aplica account_or_default INAINTE de hash (None si 1 => o cheie).
Altfel acelasi rand logic din canale diferite (account_id None pe canalul API,
1 pe import) ar primi chei diferite -> al doilea FINALIZATA duplicat.
OV-2 — skew account_id: routerul vechi pasa account_id AS-PASSED (None pe canal API
fara auth). Randurile se stocau sub account_or_default=1, dar cheia includea None.
Acelasi rand logic din import (account_id=1) dadea cheie diferita -> already_sent
rata -> al doilea FINALIZATA. Fix: build_key normalizeaza INTOTDEAUNA la
account_or_default inainte de hash.
Migrare DB productie (OV-2): randurile existente cu cheie-None nu mai sunt gasite de
build_key nou. Strategie documentata: dual-lookup la already_sent (incearca cheia
noua, apoi cheia legacy). In dev nu exista date reale; la first-deploy productie
se poate face recompute-keys o singura data.
Randuri vechi cu cheie-None nu sunt gasite de build_key nou: dual-lookup la
already_sent (cheia noua, apoi build_key_legacy) sau recompute-keys o singura data.
"""
from __future__ import annotations
@@ -46,10 +40,7 @@ def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]:
- data_prestatie: strip (normalizarea la YYYY-MM-DD se face in parser).
- prestatii: pastrate ca-atare (rezolvarea e in resolve_prestatii).
"""
# VIN
vin = (raw.get("vin") or "").strip().upper()
# Nr. inmatriculare
nr = (raw.get("nr_inmatriculare") or "").strip().upper()
# Odometru: strip ".0" Excel float coercion
@@ -82,8 +73,8 @@ def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]:
def build_key(account_id: int | None, canon: dict[str, Any]) -> str:
"""SHA-256 partajat canal-API + canal-import.
Aplica account_or_default inainte de hash (OV-2): None si 1 colapseaza la
aceeasi cheie => acelasi rand logic din canale diferite nu se trimite de doua ori.
Aplica account_or_default inainte de hash: None si 1 colapseaza la aceeasi
cheie => acelasi rand logic din canale diferite nu se trimite de doua ori.
"""
# Import local ca sa evitam import circular (mapping importa din idempotency via validator)
from .mapping import account_or_default
@@ -106,8 +97,8 @@ def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
Wrapper backward-compat peste canonicalize_row + build_key.
Exclude obs si b64Image (cosmetice, nu definesc unicitatea declaratiei).
NOTA: dupa OV-2, account_id=None si account_id=1 produc ACEEASI cheie
(via account_or_default in build_key). Randuri vechi cu cheie-None nu sunt
NOTA: account_id=None si account_id=1 produc ACEEASI cheie (via
account_or_default in build_key). Randuri vechi cu cheie-None nu sunt
acoperite automat — dual-lookup sau recompute-keys la migrare productie.
"""
canon = canonicalize_row(prezentare)
@@ -117,8 +108,8 @@ def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
def build_key_legacy(account_id: int | None, prezentare: dict[str, Any]) -> str:
"""Cheia in formatul vechi (account_id AS-PASSED, fara canonicalize).
Folosita EXCLUSIV pentru dual-lookup la already_sent pe DB cu randuri vechi
(dinainte de T9). Nu folosi pentru randuri noi.
Folosita EXCLUSIV pentru dual-lookup la already_sent pe DB cu randuri vechi.
Nu folosi pentru randuri noi.
"""
canonic = {
"account_id": account_id,

View File

@@ -1,6 +1,6 @@
"""Parser fisiere xlsx/csv pentru import prezentari (Treapta 2, U1).
"""Parser fisiere xlsx/csv pentru import prezentari (Treapta 2).
Arhitectura 2-treceri (Issue 2, consens cross-model):
Arhitectura 2-treceri:
Trecerea 1 — read_only=True: dim-check (FileTooLarge) + detectie multi-sheet.
Trecerea 2 — normal-mode: header + merged cells + body.
Aceasta separare e necesara deoarece openpyxl read_only=True nu vede celule imbinate.
@@ -29,7 +29,7 @@ from typing import Any, NamedTuple
MAX_ROWS = 5_000
MAX_BYTES = 5 * 1024 * 1024 # 5 MB
# Prag rata None pe o coloana obligatorie -> mesaj formule necalculate (Issue 3)
# Prag rata None pe o coloana obligatorie -> mesaj formule necalculate
FORMULA_NONE_RATE = 0.6
# Coloane cheie pentru detectia footer-ului (trim structural)
@@ -82,7 +82,7 @@ class ParsedFile(NamedTuple):
columns: list[str] # Numele coloanelor detectate (din header)
rows: list[dict[str, Any]] # Fiecare rand: {coloana: valoare_bruta}
coercion_flags: dict[int, list[str]] # {row_index: [motive needs_review]}
formula_columns: list[str] # Coloane cu rata None ridicata (Issue 3)
formula_columns: list[str] # Coloane cu rata None ridicata
date_col_format: dict[str, str] # {coloana: "DD.MM.YYYY" | "YYYY-MM-DD" | "native" | "ambiguous"}
@@ -230,13 +230,13 @@ def _xlsx_parse_sheet(ws, sheet_name: str) -> ParsedFile:
# Trim footer: elimina randuri trailing unde coloanele cheie sunt goale
raw_rows = _trim_footer(raw_rows, col_names)
# Detectie coloane cu formule (rata None, Issue 3)
# Detectie coloane cu formule (rata None ridicata)
formula_columns = _detect_formula_columns(col_values, len(raw_rows))
# Detectie format data la nivel de coloana (T10/OV-8)
# Detectie format data la nivel de coloana
date_col_format = _detect_date_formats(col_values, col_names)
# Coercion + flags needs_review (T3)
# Coercion + flags needs_review
coercion_flags: dict[int, list[str]] = {}
processed_rows: list[dict[str, Any]] = []
for i, row_dict in enumerate(raw_rows):
@@ -289,7 +289,7 @@ def _trim_footer(rows: list[dict[str, Any]], col_names: list[str]) -> list[dict[
# --------------------------------------------------------------------------- #
# Detectie coloane formule (Issue 3) #
# Detectie coloane formule #
# --------------------------------------------------------------------------- #
def _detect_formula_columns(col_values: dict[str, list[Any]], n_rows: int) -> list[str]:
@@ -306,7 +306,7 @@ def _detect_formula_columns(col_values: dict[str, list[Any]], n_rows: int) -> li
# --------------------------------------------------------------------------- #
# Dezambiguizare data la nivel de coloana (T10 / OV-8) #
# Dezambiguizare data la nivel de coloana #
# --------------------------------------------------------------------------- #
def _detect_date_formats(col_values: dict[str, list[Any]], col_names: list[str]) -> dict[str, str]:
@@ -344,7 +344,7 @@ def _detect_date_formats(col_values: dict[str, list[Any]], col_names: list[str])
result[col_name] = "mixed"
continue
# Toate string — detectie format la nivel de coloana (OV-8)
# Toate string — detectie format la nivel de coloana
fmt = _infer_date_format_from_column(str_vals)
result[col_name] = fmt
@@ -354,7 +354,7 @@ def _detect_date_formats(col_values: dict[str, list[Any]], col_names: list[str])
def _infer_date_format_from_column(str_vals: list[str]) -> str:
"""Detecteaza formatul datei dintr-o lista de valori string.
Logica OV-8: daca ORICARE rand are token pozitia-1 > 12 -> coloana e DD-first.
Daca ORICARE rand are token pozitia-1 > 12 -> coloana e DD-first.
Daca toti zi <= 12 -> ambiguu.
"""
dd_first_evidence = False
@@ -421,7 +421,7 @@ def _split_date(s: str) -> list[str] | None:
# --------------------------------------------------------------------------- #
# Coercion per rand (T3) #
# Coercion per rand #
# --------------------------------------------------------------------------- #
def _coerce_row(row_dict: dict[str, Any], col_names: list[str]) -> tuple[dict[str, Any], list[str]]:
@@ -682,7 +682,7 @@ def parse_csv(data: bytes) -> ParsedFile:
def parse_xlsx(data: bytes, *, sheet_name: str | None = None) -> ParsedFile:
"""Parseaza un fisier XLSX.
Arhitectura 2-treceri (Issue 2):
Arhitectura 2-treceri:
1. read_only=True: dim-check + detectie multi-sheet
2. normal-mode: header + merged cells + body

View File

@@ -1,9 +1,7 @@
"""Aplicatia FastAPI: API v1 + dashboard web + /healthz + /metrics.
Worker-ul ruleaza ca PROCES SEPARAT (python -m app.worker), NU ca task aici
(plan.md sect. 4: un worker mort nu trebuie sa lase containerul "sanatos").
Pornire dev: uvicorn app.main:app --reload
Worker-ul ruleaza ca PROCES SEPARAT (python -m app.worker), NU ca task aici:
un worker mort nu trebuie sa lase containerul "sanatos".
"""
from __future__ import annotations
@@ -44,7 +42,7 @@ from .web.session import AdminRequired, LoginRequired
async def lifespan(app: FastAPI):
install_log_redaction()
# Fail-fast: o cheie Fernet setata dar invalida opreste pornirea cu mesaj clar,
# in loc de 500 brut la primul POST /v1/prezentari (cazul reprodus din VFP).
# in loc de 500 brut la primul POST /v1/prezentari.
validate_creds_key()
init_db()
yield
@@ -61,7 +59,7 @@ app.add_middleware(
https_only=settings.session_https_only,
same_site="strict",
)
# US-002: request_id pe fiecare cerere. Adaugat dupa SessionMiddleware -> ruleaza
# request_id pe fiecare cerere. Adaugat dupa SessionMiddleware -> ruleaza
# OUTERMOST (add_middleware prepend), deci `X-Request-ID` se pune pe TOATE raspunsurile,
# inclusiv 401/404/422/500 produse mai in interior.
app.add_middleware(RequestIDMiddleware)
@@ -97,13 +95,11 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Orice excepție neprinsa -> 500 STRUCTURAT (3 niveluri, PRD 5.4) in loc de 500 brut.
"""Orice excepție neprinsa -> 500 STRUCTURAT din catalog in loc de 500 brut.
Body = envelope-ul standard din catalog (6 chei: field/cod/problema/cauza/fix/message)
+ `request_id` — fara traceback, fara mesaj de excepție brut, fara creds. Traceback-ul
complet + ruta + cont + request_id se scriu DOAR in jurnal (redactate prin scrub_text).
Handlerele specifice (LoginRequired/AdminRequired/CSRF/RequestValidationError/HTTPException)
raman neatinse — acesta prinde doar ce nu are handler dedicat.
"""
request_id = getattr(request.state, "request_id", None) or request_id_var.get()
try:
@@ -144,9 +140,8 @@ app.include_router(admin_router)
def healthz() -> dict:
"""Sanatate: worker viu + ultimul login RAR reusit + adancime coada.
Pica (200 cu ok=False / sau folosit de orchestrator) cand worker-ul e mort
-> semnal de restart (plan.md sect. 8). Intoarce 200 mereu cu detalii;
orchestratorul decide pe campul `worker_alive`.
Intoarce 200 mereu cu detalii; orchestratorul decide restartul pe campul
`worker_alive`.
"""
settings = get_settings()
conn = get_connection()

View File

@@ -1,7 +1,7 @@
"""Mapare operatie ROAAUTO -> cod prestatie RAR + fuzzy lookup pentru editor.
Contract (varianta hibrida, decis 2026-06-15): un item de prestatie poate veni
fie cu `cod_prestatie` (cod RAR direct, ca pana acum), fie cu `cod_op_service`
Contract (varianta hibrida): un item de prestatie poate veni
fie cu `cod_prestatie` (cod RAR direct), fie cu `cod_op_service`
(cod intern ROAAUTO) + `denumire`. La ingestie incercam sa rezolvam codul intern
prin `operations_mapping`; daca nu exista mapare -> submission `needs_mapping`
(nu se trimite la RAR), iar operatia apare in editorul web unde userul o mapeaza
@@ -87,7 +87,7 @@ def suggest_codes(
]
# Prefixul pus pe `cod_sursa` cand un item e rezolvat printr-o regula text (US-010).
# Prefixul pus pe `cod_sursa` cand un item e rezolvat printr-o regula text.
# Forma: "text_rule:<pattern original al regulii castigatoare>". Payload-harmless —
# RAR citeste doar `cod_prestatie`; `cod_sursa` ramane in payload_json fara efect.
COD_SURSA_TEXT_RULE_PREFIX = "text_rule:"
@@ -111,10 +111,10 @@ def _rezolva_din_reguli_text(
`valid_codes` (cand `valid_codes` e setat), nu intoarcem un cod invalid ->
(None, None, None) (operatia ramane nemapata), coerent cu garda din `resolve_prestatii`.
Pattern-ul intors e cel ORIGINAL al regulii (pentru telemetrie US-010), nu cel
Pattern-ul intors e cel ORIGINAL al regulii (pentru telemetrie), nu cel
normalizat folosit la match. `auto_send` = flagul regulii castigatoare: cand e
falsy (DEFAULT 0, decizia CEO de siguranta) randul trebuie TINUT pentru verificare
umana, nu trimis automat la RAR (blast radius substring + FINALIZATA ireversibil).
falsy (DEFAULT 0, de siguranta) randul trebuie TINUT pentru verificare umana, nu
trimis automat la RAR (blast radius substring + FINALIZATA ireversibil).
"""
if not text_rules:
return None, None, None
@@ -136,7 +136,7 @@ def _rezolva_din_reguli_text(
def text_rule_hits(resolved: list[dict] | None) -> list[dict]:
"""Extrage din itemii rezolvati cei care au primit cod dintr-o regula text (US-010).
"""Extrage din itemii rezolvati cei care au primit cod dintr-o regula text.
Intoarce [{pattern, cod_prestatie}] pentru fiecare item al carui `cod_sursa`
incepe cu `COD_SURSA_TEXT_RULE_PREFIX`. Pur (fara DB); apelantii cu `conn` il
@@ -154,7 +154,7 @@ def text_rule_hits(resolved: list[dict] | None) -> list[dict]:
def text_rules_overlap(pattern: str, existing_rules: list[dict] | None) -> list[dict]:
"""Reguli text existente care se SUPRAPUN cu `pattern` (US-011, avertisment neblocant).
"""Reguli text existente care se SUPRAPUN cu `pattern` (avertisment neblocant).
Overlap = pattern-ul nou normalizat (`normalize_for_match`) e substring al unei
reguli existente SAU invers (oricare directie). Pur, determinist, fara DB.
@@ -192,9 +192,9 @@ def resolve_prestatii(
- item fara cod, fara mapare si fara regula text -> ramane nemapat.
- item cu `cod_prestatie` NECUNOSCUT in nomenclator -> tratat ca operatie de
mapat: il promovam la `cod_op_service` (daca nu exista deja) ca sa intre in
fluxul needs_mapping. Confirmat live (2026-06-23): RAR accepta NUMAI coduri
din nomenclator (coloana COD_PRESTATIE max 5 car.); un cod necunoscut da
HTTP 500 si RECORD PARTIAL la RAR (terminal) -> nu-l trimitem niciodata raw.
fluxul needs_mapping. RAR accepta NUMAI coduri din nomenclator (coloana
COD_PRESTATIE max 5 car.); un cod necunoscut da HTTP 500 si RECORD PARTIAL
la RAR (terminal) -> nu-l trimitem niciodata raw.
Precedenta (stricta): `cod_prestatie` direct valid > mapare exacta `cod_op_service`
in `mapping` > reguli text > nemapat. Regulile text se incearca DOAR cand nu exista
@@ -217,8 +217,8 @@ def resolve_prestatii(
unmapped: list[dict] = []
for item in prestatii or []:
it = dict(item)
# Curata adnotarile aditive ale rezolvarii (cod_sursa US-010 + flagul de
# hold pe regula auto_send=0): se recalculeaza de la zero la fiecare rezolvare.
# Curata adnotarile aditive ale rezolvarii (cod_sursa + flagul de hold pe
# regula auto_send=0): se recalculeaza de la zero la fiecare rezolvare.
# Altfel, un item re-rezolvat acum prin alta cale (ex. mapare exacta) ar pastra
# un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit.
it.pop("cod_sursa", None)
@@ -246,11 +246,11 @@ def resolve_prestatii(
)
if cod_regula is not None:
it["cod_prestatie"] = cod_regula
# Adnotare aditiva (US-010): marcheaza ca rezolvat-prin-regula cu
# pattern-ul sursa. Payload-harmless (RAR citeste doar cod_prestatie).
# Adnotare aditiva: marcheaza ca rezolvat-prin-regula cu pattern-ul
# sursa. Payload-harmless (RAR citeste doar cod_prestatie).
it["cod_sursa"] = f"{COD_SURSA_TEXT_RULE_PREFIX}{pattern_regula or ''}"
# Siguranta CEO (US-001): regula cu auto_send=0 rezolva codul dar
# TINE randul pentru verificare umana (has_no_auto_send -> True).
# Siguranta: regula cu auto_send=0 rezolva codul dar TINE randul
# pentru verificare umana (has_no_auto_send -> True).
if not auto_send_regula:
it["regula_fara_autosend"] = True
else:
@@ -273,7 +273,7 @@ def account_or_default(account_id: int | None) -> int:
def account_scope_clause(account_id: int) -> tuple[str, list]:
"""Fragment SQL + params pentru filtrarea pe cont in tabele cu account_id nullable.
Aplica regula: NULL apartine contului 1 (legacy/OV-2).
Aplica regula: NULL apartine contului 1 (legacy).
Foloseste DOAR pe submissions (account_id NULLABLE).
NU folosi pe operations_mapping (account_id NOT NULL) — acolo WHERE account_id=? simplu.
"""
@@ -356,7 +356,7 @@ def load_mapping(conn, account_id: int | None) -> dict[str, str]:
def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]:
"""{cod_op_service -> {cod_prestatie, auto_send}} pentru un cont.
T6/OV-1: varianta extinsa care include si flagul auto_send per operatie.
Varianta extinsa care include si flagul auto_send per operatie.
"""
acct = account_or_default(account_id)
rows = conn.execute(
@@ -379,7 +379,7 @@ def classify_prezentare(
"""Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte.
Apelat de AMBELE rute (POST /v1/prezentari si POST /v1/prezentari/valideaza) pentru
a garanta acelasi verdict — invariantul de corectitudine dry-run (PRD 5.2).
a garanta acelasi verdict — invariantul de corectitudine dry-run.
Intoarce {"status", "rar_error", "resolved", "unmapped", "errors", "content"}.
"content" = copia actualizata (VIN/nr canonicalizat + prestatii rezolvate).
@@ -434,10 +434,10 @@ def classify_prezentare(
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).
T6/OV-1: un cod nou-mapat (operations_mapping) cu auto_send=0 nu trebuie trimis automat.
PRD 5.8 US-001 (decizia CEO): la fel pentru un item rezolvat printr-o REGULA TEXT cu
auto_send=0 — marcat de `resolve_prestatii` cu `regula_fara_autosend`. In ambele cazuri
randul ramane needs_mapping (review manual) pana cand operatorul activeaza „In coada".
Un cod nou-mapat (operations_mapping) cu auto_send=0 nu trebuie trimis automat.
La fel pentru un item rezolvat printr-o REGULA TEXT cu auto_send=0 — marcat de
`resolve_prestatii` cu `regula_fara_autosend`. In ambele cazuri randul ramane
needs_mapping (review manual) pana cand operatorul activeaza „In coada".
Items cu cod_prestatie direct (nu via cod_op_service/regula) nu sunt afectate.
"""
for item in resolved:
@@ -457,7 +457,7 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
footgun (scurge cross-account) si e rezervat exclusiv pentru dashboard-ul intern.
account_id=int: filtreaza in SQL pe cont inclusiv randuri legacy (account_id IS NULL
apartine contului 1, OV-2). Filtrarea in SQL, nu post-hoc in Python.
apartine contului 1). Filtrarea in SQL, nu post-hoc in Python.
"""
nomenclator = load_nomenclator(conn)
if account_id is not None:
@@ -577,9 +577,9 @@ def delete_text_rule(conn, account_id: int | None, pattern: str) -> None:
def _emite_text_rule_hits(conn, account_id: int, submission_id: int, resolved: list[dict] | None) -> None:
"""Emite `text_rule_hit` in app_events pentru fiecare item rezolvat prin regula text.
US-010: telemetrie „ce regula a rezolvat ce submission". Best-effort (log_event
inghite exceptiile). Context = {submission_id, account_id, pattern, cod_prestatie} —
fara PII (pattern + cod nu sunt PII). Import local: evita orice risc de ciclu la import.
Telemetrie „ce regula a rezolvat ce submission". Best-effort (log_event inghite
exceptiile). Context = {submission_id, account_id, pattern, cod_prestatie} — fara
PII (pattern + cod nu sunt PII). Import local: evita orice risc de ciclu la import.
"""
hits = text_rule_hits(resolved)
if not hits:
@@ -604,25 +604,25 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
"""Re-rezolva submission-urile `needs_mapping` ale unui cont dupa o noua mapare.
Pentru fiecare: aplica maparea curenta; daca nu mai raman op-uri nemapate ->
ruleaza validarea de continut (T3) si trece pe `queued` (sau `needs_data` cu
ruleaza validarea de continut si trece pe `queued` (sau `needs_data` cu
motiv), resetand backoff-ul. Daca raman nemapate, ramane `needs_mapping` cu
motivul actualizat. Intoarce {requeued, still_blocked, needs_data, review_manual}.
T6/OV-1: auto_send=0 pe un cod nou-mapat -> nu trece pe 'queued' (ramane
'needs_mapping' cu motiv "review manual"); previne FINALIZATA eronat permanent.
auto_send=0 pe un cod nou-mapat -> nu trece pe 'queued' (ramane 'needs_mapping'
cu motiv "review manual"); previne FINALIZATA eronat permanent.
T7: batch_id != None -> scope la seria comitata (NU cross-batch).
batch_id is None -> re-rezolva toti (canal API, batch_id IS NULL inclus).
batch_id != None -> scope la seria comitata (NU cross-batch).
batch_id is None -> re-rezolva toti (canal API, batch_id IS NULL inclus).
"""
acct = account_or_default(account_id)
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None
# T2: incarca regulile text O DATA, inainte de bucla pe randuri.
# Incarca regulile text O DATA, inainte de bucla pe randuri.
text_rules = load_text_rules(conn, acct)
if batch_id is not None:
# T7: scope la batch-ul specificat (import commit explicit).
# Scope la batch-ul specificat (import commit explicit).
# NU atinge randuri din alte batches sau din feed API.
rows = conn.execute(
"SELECT id, payload_json FROM submissions "
@@ -631,8 +631,8 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
).fetchall()
else:
# POST /v1/mapari (save manual): re-rezolva EXCLUSIV canalul API (batch_id IS NULL).
# T7/R1 INCHIS: salvarea unei mapari NU re-queues randuri din batches de import
# (cross-batch / cross-feed). Batches de import sunt re-rezolvate doar la commit explicit.
# Salvarea unei mapari NU re-queues randuri din batches de import (cross-batch /
# cross-feed). Batches de import sunt re-rezolvate doar la commit explicit.
rows = conn.execute(
"SELECT id, payload_json FROM submissions "
"WHERE status='needs_mapping' AND account_id=? AND batch_id IS NULL",
@@ -649,7 +649,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
content["prestatii"] = resolved
payload_json = json.dumps(content, ensure_ascii=False)
# US-010: telemetrie pentru itemii rezolvati prin regula text.
# Telemetrie pentru itemii rezolvati prin regula text.
_emite_text_rule_hits(conn, acct, r["id"], resolved)
if unmapped:
@@ -660,7 +660,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
stats["still_blocked"] += 1
continue
# T6/OV-1: verifica auto_send inainte de re-queuing
# Verifica auto_send inainte de re-queuing.
if has_no_auto_send(resolved, mapping_meta):
conn.execute(
"UPDATE submissions SET payload_json=?, rar_error=?, updated_at=datetime('now') WHERE id=?",

View File

@@ -1,9 +1,8 @@
"""Modele Pydantic pentru suprafata API.
ATENTIE: validarea completa (regex VIN ^[A-HJ-NPR-Z0-9]{17}$, nrInmatriculare,
dataPrestatie ∈ [2024-12-01, azi] TZ Bucuresti, R-ODO/I-ODO -> odometruInitial
obligatoriu, odometruInitial <= odometruFinal, normalizare strip/upper) este
**T3** — aici sunt doar formele de baza. Vezi plan.md sect. 2 + roadmap T3.
Aici sunt doar formele de baza + normalizare strip/upper. Validarea completa de
continut (regex VIN, interval data, R-ODO/I-ODO -> odometruInitial, ordine
odometru) este in app.validation.
"""
from __future__ import annotations
@@ -20,7 +19,7 @@ class RarCredentials(BaseModel):
class PrestatieItem(BaseModel):
"""O operatie de declarat. Contract hibrid (decis 2026-06-15):
"""O operatie de declarat. Contract hibrid:
ROAAUTO poate trimite FIE `cod_prestatie` (cod RAR direct, ex. OE-1), FIE
`cod_op_service` (cod intern ROAAUTO) + `denumire` — pe care gateway-ul le
@@ -55,7 +54,7 @@ class PrezentareIn(BaseModel):
Pydantic doar NORMALIZEAZA aici (strip/upper pe vin/nrInm). Validarea de
continut (regex VIN, interval data, R-ODO/I-ODO, odometru) e in
app.validation.validate_prezentare si NU resping cererea — marcheaza
`needs_data` (plan.md sect. 3).
`needs_data`.
"""
vin: str
@@ -102,12 +101,12 @@ class SubmissionResult(BaseModel):
status: str
id_prezentare: int | None = None
deduped: bool = False # True daca idempotency a intors un submission existent
# US-012 (decizie /autoplan #19): camp ADITIV. True cand un rand `error` cu aceeasi
# cheie de continut a fost RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit.
# `deduped` pastreaza semantica actuala (clientii vechi care testeaza `deduped` nu se sparg).
# Camp aditiv. True cand un rand `error` cu aceeasi cheie de continut a fost
# RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. `deduped` pastreaza
# semantica actuala (clientii vechi care testeaza `deduped` nu se sparg).
reactivated: bool = False
# Raspuns ONEST pentru randuri blocate (PRD 5.7): orice status != 'queued' isi
# expune motivul, ca integratorul sa nu trateze un needs_data/needs_mapping drept succes.
# Raspuns ONEST pentru randuri blocate: orice status != 'queued' isi expune
# motivul, ca integratorul sa nu trateze un needs_data/needs_mapping drept succes.
# erori = validare de continut (needs_data), 3 niveluri [{field, cod, problema, cauza, fix, message}].
# Pe ramura on_unmapped_error='error' pastreaza COD_NEMAPAT (compat).
# nemapate = coduri fara mapare RAR (needs_mapping / respins), 3 niveluri + cod_op_service/denumire.
@@ -122,7 +121,7 @@ class PrezentariResponse(BaseModel):
class ValidarePrezentariRequest(BaseModel):
"""Body pentru POST /v1/prezentari/valideaza — dry-run fara enqueue (PRD 5.2)."""
"""Body pentru POST /v1/prezentari/valideaza — dry-run fara enqueue."""
rar_credentials: RarCredentials | None = None
prezentari: list[PrezentareIn] = Field(..., min_length=1)

View File

@@ -1,12 +1,11 @@
"""Logger structurat central (PRD 5.6 US-003).
"""Logger structurat central.
Singurul punct prin care se emit evenimente de aplicatie: garanteaza format,
redactare si dublul canal (app_events in DB + log text rotativ) consistente si
imposibil de ocolit. Best-effort ca `notify_signup`: o cadere a jurnalului NU
doboara cererea/worker-ul.
imposibil de ocolit. Best-effort: o cadere a jurnalului NU doboara cererea/worker-ul.
Redactare la SCRIERE (nu la afisare): toate valorile trec prin `redact_pii`
(creds/token mascate integral, VIN/nr partial) inainte de persistare (US-007).
(creds/token mascate integral, VIN/nr partial) inainte de persistare.
"""
from __future__ import annotations
@@ -22,8 +21,8 @@ from .config import get_settings
from .db import get_connection, insert_app_event
from .security import redact_pii, scrub_text
# request_id al cererii curente (US-002). Setat de middleware-ul HTTP; disponibil
# in handlerul de erori (US-001) si aici, fara a polua semnaturile de functii.
# request_id al cererii curente. Setat de middleware-ul HTTP; disponibil in
# handlerul de erori si aici, fara a polua semnaturile de functii.
request_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"request_id", default=None
)
@@ -31,7 +30,7 @@ request_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
_LEVELS = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "WARN": 30, "ERROR": 40, "CRITICAL": 50}
# Sursa implicita a evenimentelor pentru procesul curent. API = 'api' (default);
# worker-ul cheama set_source('worker') la pornire (T5: fisier per-proces).
# worker-ul cheama set_source('worker') la pornire (fisier per-proces).
_DEFAULT_SOURCE = "api"
_loggers: dict[str, logging.Logger] = {}
@@ -46,9 +45,9 @@ def set_source(sursa: str) -> None:
def _text_logger(sursa: str) -> logging.Logger:
"""Logger cu RotatingFileHandler pe fisier per-proces (app-<sursa>.log).
Rotatia pe dimensiune e in aplicatie (decizie §5) — nu depindem de deploy.
Cheia de cache include calea: la schimbarea log_dir (teste) se creeaza un logger
nou, fara a acumula handlere duplicate pe acelasi fisier.
Rotatia pe dimensiune e in aplicatie — nu depindem de deploy. Cheia de cache
include calea: la schimbarea log_dir (teste) se creeaza un logger nou, fara a
acumula handlere duplicate pe acelasi fisier.
"""
settings = get_settings()
path = settings.log_dir / f"app-{sursa}.log"
@@ -94,10 +93,10 @@ def log_event(
) -> None:
"""Emite un eveniment: un rand `app_events` + o linie in logul text (acelasi continut redactat).
- `tip`: text liber documentat (lista extensibila, decizie §5).
- `tip`: text liber documentat (lista extensibila).
- `nivel`: DEBUG|INFO|WARNING|ERROR|CRITICAL. Sub `AUTOPASS_LOG_LEVEL` -> ignorat.
- `context`: metadate (submission_id, count, status...) — NU payload PII integral.
- `conn`: reutilizeaza conexiunea apelantului pe calea fierbinte (evita contentie WAL, T4);
- `conn`: reutilizeaza conexiunea apelantului pe calea fierbinte (evita contentie WAL);
None -> deschide/inchide o conexiune proprie.
Best-effort: orice exceptie e inghitita (jurnalul nu trebuie sa rupa fluxul).
"""

View File

@@ -1,4 +1,4 @@
"""Extragere payload submission -> campuri afisabile (US-003, PRD 3.5).
"""Extragere payload submission -> campuri afisabile.
Helper PUR partajat intre canalul web (dashboard Trimiteri) si canalul API
(`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua (decizie
@@ -115,6 +115,16 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
# cod_rar: exclusiv din cod_prestatie (NU fallback la cod_op_service); uppercase + strip ".0"
cod_rar = _clean_cod_rar(item.get("cod_prestatie"))
# Operatia de service originala (codul intern + denumire venita prin API/import),
# distincta de operatia RAR mapata (cod_rar).
# Conventie goala: aceste campuri intorc "" (string gol) cand lipsesc — NU EMPTY="—".
# Motivul: apelantul decide sa nu afiseze randul deloc (vs afisaj gol), testând `!= ""`.
# Campurile vechi (vehicul_nr, vin, operatie etc.) pastreaza conventia EMPTY="—".
op_service_cod = _clean_str(item.get("cod_op_service"))
# op_service_denumire e relevant doar cand exista un cod de operatie de service;
# altfel ar expune denumirea RAR drept op. de service, ceea ce e semantic incorect.
op_service_denumire = _clean_str(item.get("denumire")) if op_service_cod else ""
return {
"vehicul_nr": nr or EMPTY,
"vin": vin or EMPTY,
@@ -124,4 +134,7 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
"odometru": odo or EMPTY,
"cod": cod or EMPTY,
"cod_rar": cod_rar or EMPTY,
# Chei cu conventie goala "" (nu EMPTY) — vezi comentariu de mai sus
"op_service_cod": op_service_cod,
"op_service_denumire": op_service_denumire,
}

View File

@@ -40,7 +40,7 @@ SENSITIVE_KEYS = frozenset(
# Chei al caror continut e PII de identificare vehicul/proprietar: se logheaza DOAR
# partial (ultimele 4), niciodata integral (PRD 5.6 US-007, L.142/GDPR).
# partial (ultimele 4), niciodata integral (L.142/GDPR).
PII_PARTIAL_KEYS = frozenset({"vin", "nr_inmatriculare", "nr", "numar"})

View File

@@ -1,19 +1,19 @@
"""Lifecycle trimiteri blocate: sterge / re-pune in coada (PRD 5.6 US-009).
"""Lifecycle trimiteri blocate: sterge / re-pune in coada.
Inchide lacuna descoperita live: un rand `error` (creds RAR gresite) ramane altfel
permanent si nereparabil. Aceste helpere adauga DOUA tranzitii controlate —
stergere de randuri ne-sent si `blocate -> queued` (re-clasificat) — fara a atinge
logica de trimitere a worker-ului.
Inchide lacuna: un rand `error` (creds RAR gresite) ar ramane altfel permanent si
nereparabil. Aceste helpere adauga DOUA tranzitii controlate — stergere de randuri
ne-sent si `blocate -> queued` (re-clasificat) — fara a atinge logica de trimitere
a worker-ului.
Invariante (decizii §2 + /autoplan #20):
Invariante:
- Opereaza DOAR pe `error`/`needs_data`/`needs_mapping`. `sent` (dovada de trimitere
la RAR, audit) si `sending` (lease worker in zbor) sunt INTERZISE.
- Scope-ul (apartenenta la cont) se evalueaza INAINTEA starii: un rand inexistent SAU
al altui cont -> SubmissionNotFound (404, nu confirmam existenta, B3). Doar pe randuri
al altui cont -> SubmissionNotFound (404, nu confirmam existenta). Doar pe randuri
proprii in stare gresita -> SubmissionStateConflict (409).
- Ambele emit eveniment in jurnal (US-003): `submission_sters` / `submission_repus`.
- Ambele emit eveniment in jurnal: `submission_sters` / `submission_repus`.
Functii cu `conn` (persistenta). Apelate din API (US-010) si din web (US-011).
Functii cu `conn` (persistenta).
"""
from __future__ import annotations
@@ -80,7 +80,7 @@ def requeue_submission(conn, account_id: int, sid: int) -> dict:
`error -> queued` (cand continutul e valid) sau ramane `needs_data`/`needs_mapping`
daca clasificarea o cere. Reseteaza retry_count/next_attempt_at/sending_since si
CURATA `purge_after` (randul redevine activ, nu mai e candidat la purjare — US-013).
CURATA `purge_after` (randul redevine activ, nu mai e candidat la purjare).
Ridica SubmissionNotFound / SubmissionStateConflict. Intoarce
{"submission_id", "status_anterior", "status_nou"}.
"""

View File

@@ -1,8 +1,8 @@
"""Helper-e utilizatori web (email + parola scrypt). US-001 PRD 3.3.
"""Helper-e utilizatori web (email + parola scrypt).
Parola NICIODATA stocata in clar. Fiecare user are un salt per-user generat cu
secrets.token_bytes(16). Parametrii scrypt stocati ca eticheta de versiune pentru
migrare cost viitoare (C9).
migrare cost viitoare.
"""
from __future__ import annotations
@@ -98,7 +98,7 @@ def set_admin(conn: sqlite3.Connection, account_id: int, is_admin: bool = True)
"""Seteaza/sterge rolul admin pe toti userii contului dat.
Ridica ValueError daca contul nu exista.
Daca contul exista dar nu are useri, e no-op silentios (confom spec US-010).
Daca contul exista dar nu are useri, e no-op silentios.
"""
acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
if not acct:
@@ -119,7 +119,7 @@ def is_account_admin(conn: sqlite3.Connection, account_id: int) -> bool:
def list_admin_emails(conn: sqlite3.Connection) -> list[str]:
"""Returneaza emailurile tuturor userilor cu is_admin=1 (folosit de US-012)."""
"""Returneaza emailurile tuturor userilor cu is_admin=1."""
rows = conn.execute(
"SELECT email FROM users WHERE is_admin=1"
).fetchall()

View File

@@ -1,4 +1,4 @@
"""Panou admin web /admin. US-011 PRD 3.3b.
"""Panou admin web /admin.
Rute:
GET /admin — listeaza conturi in asteptare + active (require_admin)
@@ -49,7 +49,7 @@ def _render_admin(request: Request, conn, *, error: str | None = None, status_co
emails = _emails_by_account(conn)
for acct in accounts:
acct["email"] = emails.get(acct["id"])
# Grupare pe STARE (5.5), nu pe `active`: altfel conturile arhivate/blocate (active=0)
# Grupare pe STARE, nu pe `active`: altfel conturile arhivate/blocate (active=0)
# ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts.
pending = [a for a in accounts if a["status"] == "pending" and a["id"] != 1]
active = [a for a in accounts if a["status"] == "active" and a["id"] != 1]
@@ -79,7 +79,7 @@ async def admin_get(request: Request):
def _apply_lifecycle(conn, ids: list[int], action: str) -> None:
"""Aplica un verb de ciclu de viata (5.5) pe o lista de conturi. Conturile protejate
"""Aplica un verb de ciclu de viata pe o lista de conturi. Conturile protejate
(id=1) sau inexistente ridica ValueError din helperi -> sarite (nu opresc bulk-ul).
`action`: activate | block | archive | delete."""
for aid in ids:
@@ -97,7 +97,7 @@ def _apply_lifecycle(conn, ids: list[int], action: str) -> None:
def _lifecycle_route(request: Request, account_id: list[int], csrf_token: str, action: str):
"""Corp comun pentru rutele de ciclu de viata (5.5): auth + CSRF + aplica verbul (bulk) + PRG.
"""Corp comun pentru rutele de ciclu de viata: auth + CSRF + aplica verbul (bulk) + PRG.
Evita 4 handlere copy-paste care difera doar prin verb."""
require_admin(request)
verify_csrf(request, csrf_token)

View File

@@ -1,4 +1,4 @@
"""Rute autentificare web: /signup (US-003), /login + /logout (US-004). PRD 3.3."""
"""Rute autentificare web: /signup, /login, /logout."""
from __future__ import annotations

View File

@@ -1,4 +1,4 @@
"""CSRF token per-sesiune + validare. US-009 PRD 3.3.
"""CSRF token per-sesiune + validare.
Contract pentru rutele POST web:
- Formulare HTML includ: <input type="hidden" name="csrf_token" value="{{ csrf_token }}">

View File

@@ -1,9 +1,6 @@
"""
labels.py — traducere stari tehnice in text uman + clasa CSS (US-001, PRD 3.4).
"""Traducere stari tehnice in text uman + clasa CSS.
Functii pure: fara DB, fara request. Usor de testat unitar si de importat in template-uri.
Sursa de adevar pentru texte: tabelul din PRD 3.4 §3 US-001.
"""
import json
@@ -59,7 +56,7 @@ STARI_SUBMISSION: dict[str, Eticheta] = {
# ---------------------------------------------------------------------------
# Etichete scurte (pill) pentru coloana Stare din tabelul de trimiteri (US-006)
# Etichete scurte (pill) pentru coloana Stare din tabelul de trimiteri
# Dict propriu — NU element in tuple Eticheta (ar rupe template-urile care
# despacheteaza 3 elemente). eticheta_stare ramane neatinsa.
# ---------------------------------------------------------------------------
@@ -155,7 +152,7 @@ def eticheta_rar(stare: str) -> Eticheta:
# ---------------------------------------------------------------------------
# Format data RAR (US-001, PRD 3.5)
# Format data RAR
# ---------------------------------------------------------------------------
def format_data_rar(raw: object) -> str:
@@ -181,7 +178,7 @@ def format_data_rar(raw: object) -> str:
# ---------------------------------------------------------------------------
# Motiv uman din rar_error (US-004, PRD 3.5)
# Motiv uman din rar_error
# ---------------------------------------------------------------------------
def motiv_uman(status: str, rar_error: object) -> str:
@@ -231,7 +228,7 @@ def motiv_uman(status: str, rar_error: object) -> str:
# ---------------------------------------------------------------------------
# parse_erori — transforma rar_error in lista 3-niveluri (US-006, PRD 5.4)
# parse_erori — transforma rar_error in lista 3-niveluri
# ---------------------------------------------------------------------------
def parse_erori(rar_error: object) -> list[dict]:
@@ -275,7 +272,7 @@ def parse_erori(rar_error: object) -> list[dict]:
"cauza": e.get("cauza") or e.get("message") or "",
"fix": e.get("fix") or "",
"field": e.get("field"),
# Cod BRUT de catalog (ex. RAR_EROARE_SERVER) — DOAR pentru modal (US-001/R1).
# Cod BRUT de catalog (ex. RAR_EROARE_SERVER) — DOAR pentru modal.
"cod": e.get("cod"),
})
else:
@@ -305,7 +302,7 @@ def parse_erori(rar_error: object) -> list[dict]:
"cauza": data.get("cauza") or "",
"fix": data.get("fix") or "",
"field": data.get("field"),
# Cod BRUT de catalog (ex. COD_NEMAPAT) — DOAR pentru modal (US-001/R1).
# Cod BRUT de catalog (ex. COD_NEMAPAT) — DOAR pentru modal.
"cod": data.get("cod"),
}]
# Dict vechi: unmapped

View File

@@ -1,8 +1,8 @@
"""Middleware HTTP: request_id per cerere (PRD 5.6 US-002).
"""Middleware HTTP: request_id per cerere.
Fiecare raspuns primeste un header `X-Request-ID` (generat daca clientul nu trimite
unul). Pe durata cererii, id-ul e disponibil prin `observ.request_id_var` (contextvar)
in handlerul de erori (US-001) si in `log_event` (US-003) — fara a polua semnaturile.
in handlerul de erori si in `log_event` — fara a polua semnaturile.
Format opac, fara PII: `secrets.token_hex(8)` (16 hex). Daca clientul trimite un
`X-Request-ID`, il pastram (corelare end-to-end), dar il scurtam defensiv (max 64).

View File

@@ -1,6 +1,6 @@
"""Rate-limit in-proces cu fereastra glisanta. US-009 PRD 3.3 C5.
"""Rate-limit in-proces cu fereastra glisanta.
Fara dependinta externa. Folosit de POST /signup (US-003) cu cheia = IP client.
Fara dependinta externa. Folosit de POST /signup cu cheia = IP client.
Configurabil prin AUTOPASS_signup_rate_max / AUTOPASS_signup_rate_window_s (config.py).
"""

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
"""Helper-e sesiune web. US-002 PRD 3.3.
"""Helper-e sesiune web.
Mecanism require_login (C11): NU un dependency FastAPI care intoarce RedirectResponse
Mecanism require_login: NU un dependency FastAPI care intoarce RedirectResponse
(acela nu scurtcircuiteaza handler-ul — FastAPI continua executia). In schimb:
- require_login() RIDICA LoginRequired
- app.main inregistreaza @app.exception_handler(LoginRequired) care intoarce
@@ -31,7 +31,7 @@ def current_account(request: Request) -> int | None:
def current_user_id(request: Request) -> int | None:
"""user_id din sesiune sau None (C19: leaga import_attestations.confirmed_by)."""
"""user_id din sesiune sau None (leaga import_attestations.confirmed_by)."""
val = request.session.get("user_id")
return int(val) if val is not None else None
@@ -88,7 +88,7 @@ def require_admin(request: Request) -> int:
def set_session(request: Request, account_id: int, user_id: int) -> None:
"""Seteaza sesiunea dupa login. Curata mai intai (C3 anti-fixare sesiune)."""
"""Seteaza sesiunea dupa login. Curata mai intai (anti-fixare sesiune)."""
request.session.clear()
request.session["account_id"] = account_id
request.session["user_id"] = user_id

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -44,12 +44,8 @@
</div>
{% endif %}
{# US-001 (5.5): randul "Ajutor" (wayfinding Mapari/Coduri RAR) eliminat — navigarea
traieste in tab-bar (Mapari) si in meniul de cont (Nomenclator etc.). #}
{# === Sectiunea Trimiteri ("Trimiterile tale"), permanenta sub upload (US-003).
Suprimata la first-run (zero trimiteri): bara de upload acopera deja CTA-ul,
iar empty-state-ul tabelului ar fi redundant (US-004 / D-5.1). === #}
{# Sectiunea Trimiteri, permanenta sub upload. Suprimata la first-run (zero
trimiteri): bara de upload acopera deja CTA-ul, iar empty-state-ul ar fi redundant. #}
{% if are_trimiteri %}
{% include '_coada.html' %}
{% endif %}

View File

@@ -1,8 +1,6 @@
{#
_coada.html — repurposat in 3.6 (US-003).
Nu mai e un tab/panou separat: e sectiunea "Trimiterile tale" inclusa pe Acasa,
sub zona de upload. Pastreaza filtrele (US-009) si tabelul (_submissions.html); detaliul
se deschide acum in modalul global (#modal-detaliu). Poll aliniat la 15s (anti dublu-poll, M5).
_coada.html — sectiunea "Trimiterile tale" inclusa pe Acasa, sub zona de upload.
Filtre + tabel (_submissions.html); detaliul se deschide in modalul global (#modal-detaliu).
#}
<section id="trimiteri-section" aria-labelledby="trimiteri-heading"
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);">
@@ -21,28 +19,17 @@
</span>
</div>
<!-- Filtre (US-009): reincarca tabelul; poll-ul re-trimite filtrul curent prin hx-include -->
<!-- Bara de filtre: vehicul/data + pill-uri de stare pe acelasi rand. Pill-urile scriu
campul hidden status si re-trimit form-ul (filtreazaStare) -> filtrul persista la reincarcari. -->
<form id="filtre-trimiteri"
hx-get="/_fragments/submissions"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
hx-trigger="submit, change, keyup delay:400ms from:input[name='vehicul']"
style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
<div>
<label for="f-status" class="muted" style="display:block; font-size:12px;">Stare</label>
{# US-014/T13: status_filtru (din deep-link ?tab=acasa&status=) pre-selecteaza
starea, iar submissions-wrap (hx-include #filtre-trimiteri) o incarca filtrat. #}
{% set sf = status_filtru | default('') %}
<select id="f-status" name="status">
<option value="" {% if not sf %}selected{% endif %}>toate</option>
<option value="queued" {% if sf == 'queued' %}selected{% endif %}>in asteptare</option>
<option value="sent" {% if sf == 'sent' %}selected{% endif %}>declarate la RAR</option>
<option value="needs_mapping" {% if sf == 'needs_mapping' %}selected{% endif %}>lipsa cod</option>
<option value="needs_data" {% if sf == 'needs_data' %}selected{% endif %}>date incomplete</option>
<option value="error" {% if sf == 'error' %}selected{% endif %}>eroare</option>
<option value="sending" {% if sf == 'sending' %}selected{% endif %}>se trimite</option>
</select>
</div>
style="display:flex; gap:10px 14px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
<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. #}
<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>
<input id="f-vehicul" type="text" name="vehicul" placeholder="ex. B123 sau VIN" style="max-width:180px;">
@@ -56,19 +43,27 @@
<input id="f-data-pana" type="date" name="data_pana">
</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' %}
</span>
</form>
<!-- Poll aliniat la 15s ca status-ul (M5: nu doua timere perpetue pe pagina mereu deschisa) -->
<!-- Nudge "Date noi": tabelul nu se reimprospateaza singur; bannerul apare doar cand
pollerul usor detecteaza schimbari, iar utilizatorul reincarca cand vrea. -->
<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"
hx-get="/_fragments/submissions"
hx-trigger="load, every 15s, trimiteriChanged from:body"
hx-trigger="load, trimiteriChanged from:body, reincarcaTrimiteri"
hx-include="#filtre-trimiteri" hx-swap="innerHTML">
<div class="empty">se incarca…</div>
</div>
</div>
{# PRD 5.9 US-003: detaliul s-a mutat intr-un MODAL global (#modal-detaliu in base.html),
in afara #submissions-wrap -> poll-ul de 15s nu-l mai atinge. Randul declanseaza
deschiderea (hx-target=#detaliu-modal-body). Vechiul panou inert #trimitere-detaliu
a fost eliminat (rol preluat de modal). #}
</section>

View File

@@ -1,5 +1,5 @@
{#
_eroare.html — macro card_erori(erori) (US-006, PRD 5.4).
_eroare.html — macro card_erori(erori).
Primeste o lista de dict-uri cu cheile: problema, cauza, fix, field (sau None).
Afiseaza 3 niveluri intr-un bloc scannabil:

View File

@@ -1,6 +1,6 @@
{# _jurnal.html — tab Jurnal de aplicatie (US-006, PRD 5.6).
{# _jurnal.html — tab Jurnal de aplicatie.
Lista paginata de evenimente (app_events), redactate la scriere. Filtre tip/nivel/
data + (admin) cont. Stil consistent cu tabelele PRD 5.5 (.tablewrap). #}
data + (admin) cont. #}
<section id="jurnal-section" aria-labelledby="jurnal-heading">
<div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">

View File

@@ -1,18 +1,14 @@
{# Macro-uri partajate intre template-urile de import si mapari. #}
{# US-003 (5.5): comutator COMPACT "In coada" — Manual <-> Auto pe un singur rand, fara
proza inline (explicatia traieste o singura data in panoul Ajutor al cardului Mapari).
Inlocuieste varianta verbose din 3.6 care injecta 3 randuri de text pe FIECARE linie de
tabel (randuri inalte -> Salveaza/Sterge ieseau din viewport).
INVARIANT BACKEND (nealterat din 3.6): control = checkbox cu `name="auto_send" value="true"`
si SEMANTICA DE PREZENTA (bifat -> trimite "true" -> True; nebifat -> camp absent -> False).
{# Comutator COMPACT "In coada" — Manual <-> Auto pe un singur rand.
INVARIANT BACKEND: control = checkbox cu `name="auto_send" value="true"` si
SEMANTICA DE PREZENTA (bifat -> trimite "true" -> True; nebifat -> camp absent -> False).
E singura forma compatibila cu AMBELE parsere: `Form(bool)` la /mapari SI `bool(form.get())`
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. Zero atingere backend.
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 (H4) — bifat = Auto. #}
- 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."

View File

@@ -4,7 +4,7 @@
/* Selectul de cod RAR e principalul vinovat de latimea tabelelor de mapari. Il limitam ca
tabelul sa incapa in card fara scroll orizontal -> coloana Actiuni (kebab) ramane vizibila. */
#mapari-section td select { width:100%; max-width:240px; min-width:150px; }
/* US-007 (R12): in card per rand (sub 767px) selectul/inputurile umplu cardul. */
/* In card per rand (sub 767px) selectul/inputurile umplu cardul. */
@media (max-width:767px) {
#mapari-section td select, #mapari-section td input[type=text] { max-width:none; min-width:0; }
}
@@ -18,29 +18,9 @@
<!-- Sectiunea 1: De rezolvat (operatii needs_mapping) -->
<!-- ============================================================ -->
<div class="card">
{# US-005 (5.5): antet standard + link Ajutor ca <details> nativ (fara JS). Toata proza
care inainte se repeta inline (scopul maparilor, Auto/Manual) traieste acum AICI,
o singura data, ascunsa implicit. #}
<h2 style="font-size:15px; margin:0 0 8px;">De rezolvat</h2>
<details class="ajutor-mapari" style="margin:0 0 12px;">
<summary class="cardlink" style="display:inline-flex; color:var(--accent); cursor:pointer; padding:4px 0;">Ajutor</summary>
<div class="muted" style="font-size:13px; margin-top:8px; max-width:680px;">
Maparile leaga o operatie din softul tau (cod intern ROAAUTO) de un cod RAR oficial.
Operatiile necunoscute raman blocate in <span class="s-needs_mapping">needs_mapping</span>
si NU pleaca la RAR pana le mapezi. Sugestiile (%) vin din potrivire fuzzy pe denumire —
verifica-le inainte sa salvezi. <strong>In coada</strong>: <strong>Auto</strong> = la
urmatoarele fisiere cu aceasta operatie randurile intra automat in coada;
<strong>Manual</strong> = raman pentru verificare, nimic nu pleaca la RAR pana confirmi.
La schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
</div>
</details>
{% if not pending %}
<div class="empty">
Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR.
<a href="/?tab=acasa">Importa un fisier nou</a> daca vrei sa adaugi prezentari.
</div>
{% else %}
{% if pending %}
<div data-dt="10">
<div class="dt-tools">
<input type="search" data-dt-search class="dt-search"
@@ -117,8 +97,6 @@
Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand.
</div>
{% else %}
{# US-005 (5.5): proza explicativa mutata in panoul Ajutor de la "De rezolvat" (o singura data). #}
<div data-dt="10">
<div class="dt-tools">
<input type="search" data-dt-search class="dt-search"
@@ -165,15 +143,24 @@
{{ 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">
{# Salveaza/Sterge in meniu contextual (kebab) — randul ramane ingust. Butoanele se
leaga prin form= de cele doua form-uri hx-post definite in prima celula a randului. #}
<details class="kebab">
<summary aria-label="Actiuni pentru {{ m.cod_op_service }}">&#8943;</summary>
<div class="kebab-menu">
<button type="submit" form="map-salv-{{ loop.index }}">Salveaza</button>
<button type="submit" form="map-del-{{ loop.index }}" class="danger">Sterge</button>
</div>
</details>
{# 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,
JS adauga clasa "dirty" pe butonul de salvare (fundal --accent = modificari nesalvate). #}
<button type="submit" form="map-salv-{{ loop.index }}"
class="icon-btn"
data-dirty-form="map-salv-{{ loop.index }}"
aria-label="Salveaza maparea pentru {{ m.cod_op_service }}">
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
<path d="M2 2a1 1 0 011-1h7.5L13 3.5V14a1 1 0 01-1 1H3a1 1 0 01-1-1V2zm5 10a2 2 0 100-4 2 2 0 000 4zM3 3v3h6V3H3z"/>
</svg>
</button>
<button type="submit" form="map-del-{{ loop.index }}"
class="icon-btn danger"
aria-label="Sterge maparea pentru {{ m.cod_op_service }}">
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
<path d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6zM14 3a1 1 0 01-1 1H3a1 1 0 110-2h3.5l1-1h2l1 1H13a1 1 0 011 1zm-1 1H3v9a1 1 0 001 1h8a1 1 0 001-1V4z"/>
</svg>
</button>
</td>
</tr>
{% endfor %}
@@ -187,79 +174,7 @@
</div>
<!-- ============================================================ -->
<!-- Sectiunea 3: Formate de coloane salvate (column_mappings) -->
<!-- ============================================================ -->
<div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">Formate de coloane salvate</h2>
{% if not column_formats %}
<div class="empty">
Niciun format de coloane salvat inca. La primul import, maparea coloanelor fisierului
se retine aici si se reaplica automat la fisierele cu acelasi antet.
</div>
{% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat.
</p>
<div data-dt="10">
<div class="dt-tools">
<input type="search" data-dt-search class="dt-search"
placeholder="Cauta coloana sau camp..." aria-label="Cauta in formatele de coloane">
</div>
<div class="tablewrap tabel-card">
<table>
<thead><tr>
<th>Coloane</th>
<th>Mapari (coloana &rarr; camp)</th>
<th>Format data</th>
<th></th>
</tr></thead>
<tbody>
{% for f in column_formats %}
<tr>
<td style="white-space:nowrap;" data-eticheta="Coloane">
<strong>{{ f.columns | length }} coloane</strong>
</td>
<td class="muted" style="font-size:12px; white-space:normal; max-width:340px;" data-eticheta="Mapari (coloana &rarr; camp)">
{% for col, camp in f.mappings.items() %}
<span class="sugg">{{ col }}</span> &rarr; {{ camp }}{% if not loop.last %}; {% endif %}
{% endfor %}
</td>
<td data-eticheta="Format data">
<form id="fmt-edit-{{ loop.index }}" hx-post="/formate-coloane/editeaza"
hx-target="#mapari-section" hx-swap="outerHTML"
style="display:flex; gap:6px; align-items:center;">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="format_id" value="{{ f.id }}">
<input type="text" name="format_data" value="{{ f.format_data or '' }}"
placeholder="ex. DD.MM.YYYY" aria-label="Format data" style="max-width:130px;">
<button type="submit">Salveaza data</button>
</form>
</td>
<td>
<form hx-post="/formate-coloane/sterge" hx-target="#mapari-section" hx-swap="outerHTML"
hx-confirm="Stergi acest format de coloane?">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="format_id" value="{{ f.id }}">
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);">
Sterge
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="dt-empty" data-dt-empty style="display:none;">Niciun format nu se potriveste cautarii.</div>
<div class="dt-pager" data-dt-pager></div>
</div>
{% endif %}
</div>
<!-- ============================================================ -->
<!-- Sectiunea 4: Reguli automate pe text (operation_text_rules) -->
<!-- Sectiunea 3: Reguli automate pe text (operation_text_rules) -->
<!-- ============================================================ -->
<div class="card">
<h2 style="font-size:15px; margin:0 0 8px;">Reguli automate (text)</h2>
@@ -343,7 +258,7 @@
<button type="submit" form="rt-add">Adauga</button>
</td>
</tr>
{# Preview pre-salvare (US-009): cate operatii nemapate potriveste pattern-ul. #}
{# Preview pre-salvare: cate operatii nemapate potriveste pattern-ul. #}
<tr>
<td colspan="4" style="padding-top:0;">
<div id="rt-preview" aria-live="polite"></div>
@@ -354,4 +269,76 @@
</div>
</div>
<!-- ============================================================ -->
<!-- Sectiunea 4: Formate de coloane salvate (column_mappings) -->
<!-- ============================================================ -->
<div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">Formate de coloane salvate</h2>
{% if not column_formats %}
<div class="empty">
Niciun format de coloane salvat inca. La primul import, maparea coloanelor fisierului
se retine aici si se reaplica automat la fisierele cu acelasi antet.
</div>
{% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat.
</p>
<div data-dt="10">
<div class="dt-tools">
<input type="search" data-dt-search class="dt-search"
placeholder="Cauta coloana sau camp..." aria-label="Cauta in formatele de coloane">
</div>
<div class="tablewrap tabel-card">
<table>
<thead><tr>
<th>Coloane</th>
<th>Mapari (coloana &rarr; camp)</th>
<th>Format data</th>
<th></th>
</tr></thead>
<tbody>
{% for f in column_formats %}
<tr>
<td style="white-space:nowrap;" data-eticheta="Coloane">
<strong>{{ f.columns | length }} coloane</strong>
</td>
<td class="muted" style="font-size:12px; white-space:normal; max-width:340px;" data-eticheta="Mapari (coloana &rarr; camp)">
{% for col, camp in f.mappings.items() %}
<span class="sugg">{{ col }}</span> &rarr; {{ camp }}{% if not loop.last %}; {% endif %}
{% endfor %}
</td>
<td data-eticheta="Format data">
<form id="fmt-edit-{{ loop.index }}" hx-post="/formate-coloane/editeaza"
hx-target="#mapari-section" hx-swap="outerHTML"
style="display:flex; gap:6px; align-items:center;">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="format_id" value="{{ f.id }}">
<input type="text" name="format_data" value="{{ f.format_data or '' }}"
placeholder="ex. DD.MM.YYYY" aria-label="Format data" style="max-width:130px;">
<button type="submit">Salveaza data</button>
</form>
</td>
<td>
<form hx-post="/formate-coloane/sterge" hx-target="#mapari-section" hx-swap="outerHTML"
hx-confirm="Stergi acest format de coloane?">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="format_id" value="{{ f.id }}">
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);">
Sterge
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="dt-empty" data-dt-empty style="display:none;">Niciun format nu se potriveste cautarii.</div>
<div class="dt-pager" data-dt-pager></div>
</div>
{% endif %}
</div>
</div>

View File

@@ -1,7 +1,5 @@
{# US-002 (5.5): aceeasi grila standard ca tabelul Trimiteri (_submissions.html):
.tablewrap > table, antet th standard (mostenit din base.html), cod in .pill,
denumire ca text normal (singura coloana care se poate rupe pe randuri inguste),
empty-state in .empty. Zero stiluri inline noi — totul vine din base.html. #}
{# Aceeasi grila standard ca tabelul Trimiteri: cod in .pill, denumire ca text normal
(singura coloana care se poate rupe pe randuri inguste), empty-state in .empty. #}
{% if rows %}
<div class="tablewrap">
<table>

View File

@@ -0,0 +1,15 @@
{# Pill-uri de filtrare a starii, randate in bara de filtre (_coada.html) si re-randate
prin OOB la fiecare reincarcare a tabelului (_submissions.html). Stare activa =
status_filtru. "Toate" reseteaza filtrul; categoriile apar doar cand au n>0. #}
<button type="button" class="pill-cat pill-cat-reset" data-status=""
aria-pressed="{{ 'true' if not status_filtru else 'false' }}"
onclick="filtreazaStare(this, '')">Toate</button>
{% for pill in pills_categorii %}
<button type="button" class="pill-cat" data-status="{{ pill.status }}"
aria-pressed="{{ 'true' if status_filtru == pill.status else 'false' }}"
style="color:var({{ pill.color_var }}); border-color:var({{ pill.color_var }});"
onclick="filtreazaStare(this, '{{ pill.status }}')">
{{ pill.label }}
<span class="pill-cat-n" style="background:var({{ pill.color_var }});">{{ pill.n }}</span>
</button>
{% endfor %}

View File

@@ -17,7 +17,7 @@
</div>
{% endif %}
<!-- Rezumat stari (id stabil pentru OOB swap dupa editarea unui rand — US-002) -->
<!-- Rezumat stari (id stabil pentru OOB swap dupa editarea unui rand) -->
{% set status_labels = [
('ok', 'gata de trimis'),
('needs_review', 'verifica valori'),
@@ -108,7 +108,7 @@
{% endif %}
<!-- Tabel preview. Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form,
altfel Enter intr-un camp ar declansa trimiterea ireversibila — D-3.3). Bifele
altfel Enter intr-un camp ar declansa trimiterea ireversibila). Bifele
needs_review se asociaza la #confirm-form prin atributul form=. -->
<div class="tablewrap">
<table>
@@ -142,7 +142,7 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<div class="sticky-bar">
<div style="flex:1; min-width:280px;">
<!-- Banner declarant (D12) — direct deasupra input-ului N -->
<!-- Banner declarant — direct deasupra input-ului N -->
<div class="banner warn" style="margin-bottom:10px; padding:8px 12px; border-radius:6px;"
role="note" aria-live="polite">
Confirmand, TU esti declarantul acestor
@@ -199,7 +199,7 @@
</form>
<!-- Contor "gata de trimis" citit din DOM (data-ok), ca OOB swap-ul de la editare
sa actualizeze N fara a re-randa sectiunea (US-002). -->
sa actualizeze N fara a re-randa sectiunea. -->
<span id="preview-ok-count" data-ok="{{ summary.get('ok', 0) }}" hidden></span>
<div style="padding:8px 0 4px;">
@@ -212,13 +212,13 @@
<script>
(function() {
/* D-1.2: un singur sticky bar pe ecran — cat preview-ul de import e activ,
/* Un singur sticky bar pe ecran — cat preview-ul de import e activ,
ascunde sectiunea Trimiteri de pe Acasa (se reveleaza la reset/commit din _upload.html). */
var trim = document.getElementById('trimiteri-section');
if (trim) trim.style.display = 'none';
/* nOk se citeste din DOM (#preview-ok-count[data-ok]) ca OOB swap-ul de la editare
sa-l poata actualiza fara re-randarea sectiunii (D-3.1/D-3.4). */
sa-l poata actualiza fara re-randarea sectiunii. */
function getOk() {
var el = document.getElementById('preview-ok-count');
return el ? parseInt(el.dataset.ok || '0', 10) : 0;
@@ -231,7 +231,7 @@
var inp = document.getElementById('n-confirmat');
var disp = document.getElementById('n-display');
var btn = document.getElementById('confirm-btn');
/* Nu re-activa confirm cat un rand e in editare (mutual-exclusion D-3.2). */
/* Nu re-activa confirm cat un rand e in editare (mutual-exclusion). */
var editing = document.querySelector('tr[data-editing="1"]') !== null;
if (inp) inp.value = total;
if (disp) disp.textContent = total;

View File

@@ -1,10 +1,10 @@
{#
_preview_rand.html — un singur rand de preview import (US-002, 3.6).
_preview_rand.html — un singur rand de preview import.
Doua moduri:
- display (editing falsy): <tr> normal + buton "Editeaza" pe coloana de actiuni.
- edit (editing truthy): <tr> cu un singur <td colspan> ce contine un FORM PROPRIU
(NU #confirm-form) cu grila responsiva refolosita din _trimitere_detaliu.html.
Swap pe RAND (hx-target pe #preview-row-N, outerHTML), NU pe #import-section (D-3.1).
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).
#}
{%- set res = row.resolved -%}
@@ -80,7 +80,7 @@
</tr>
<script>
(function() {
/* Mutual-exclusion (D-3.2/3.6): cat un rand e in editare, dezactiveaza confirm + alte Editeaza. */
/* Mutual-exclusion: cat un rand e in editare, dezactiveaza confirm + alte Editeaza. */
var btn = document.getElementById('confirm-btn');
if (btn) { btn.disabled = true; btn.title = 'Termina editarea randului inainte de a trimite.'; }
document.querySelectorAll('.btn-editeaza').forEach(function(b) { b.disabled = true; });
@@ -152,7 +152,7 @@
</td>
</tr>
{% if include_oob %}
{# OOB: actualizeaza rezumatul si contorul "gata de trimis" dupa save, fara a re-randa sectiunea (D-3.1). #}
{# OOB: actualizeaza rezumatul si contorul "gata de trimis" dupa save, fara a re-randa sectiunea. #}
{% set status_labels = [
('ok','gata de trimis'), ('needs_review','verifica valori'), ('needs_mapping','fara cod RAR'),
('needs_data','date lipsa'), ('already_sent','deja trimis'), ('duplicate_in_file','dublicat in fisier')] %}

View File

@@ -47,38 +47,6 @@
</span>
</div>
<!-- Necesita atentia ta (US-014): categorii actionabile — link la lista filtrata
+ identificatorii primelor randuri blocate. Se randeaza DOAR daca exista randuri
blocate; cand contorul ajunge 0 (sters/re-pus/purjat), sectiunea dispare. -->
{% if blocate_actionabil %}
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
<div style="font-size:13px; font-weight:600; margin-bottom:8px;">Necesita atentia ta</div>
<div style="display:flex; gap:18px; flex-wrap:wrap;">
{% for cat in blocate_actionabil %}
<div style="min-width:200px;">
{# Link: filtreaza lista Trimiteri pe aceasta stare (HTMX in-place) cu fallback
deep-link server-side (?tab=acasa&status=...). #}
<a class="{{ cat.eticheta[2] }}" style="font-size:13px; font-weight:600; text-decoration:none;"
href="/?tab=acasa&status={{ cat.status }}"
hx-get="/_fragments/submissions?status={{ cat.status }}"
hx-target="#submissions-wrap" hx-swap="innerHTML"
onclick="var s=document.getElementById('trimiteri-section'); if(s) s.scrollIntoView({behavior:'smooth'});">
{{ cat.eticheta[0] }} ({{ cat.n }}) &rsaquo;
</a>
<ul style="list-style:none; margin:6px 0 0; padding:0;">
{% for r in cat.randuri %}
<li class="muted" style="font-size:12px;">
#{{ r.id }} {{ r.vin }}{% if r.nr %} / {{ r.nr }}{% endif %}
</li>
{% endfor %}
{% if cat.rest %}
<li class="muted" style="font-size:12px;">…si inca {{ cat.rest }}</li>
{% endif %}
</ul>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# Pill-urile de stare s-au mutat in bara de filtre din sectiunea Trimiteri (_coada.html). #}
</div>

View File

@@ -1,5 +1,19 @@
{#
OOB: actualizeaza inputul id="f-page" din #filtre-trimiteri.
Reincarcarea (hx-include="#filtre-trimiteri") preia automat pagina curenta.
Elementul OOB e extras din continutul normal de HTMX inainte de swap in #submissions-wrap.
#}
<input type="hidden" id="f-page" name="page" value="{{ page | default(1) }}" hx-swap-oob="true">
{# OOB: re-randeaza pill-urile de stare (in bara de filtre, in afara #submissions-wrap) cu
contoarele si starea activa proaspete la fiecare reincarcare a tabelului. #}
<span hx-swap-oob="innerHTML:#pills-categorii">{% include '_pills.html' %}</span>
{# Versiunea datelor cu care s-a randat tabelul; pollerul "Date noi" o compara. #}
<span id="trimiteri-versiune" data-v="{{ versiune_trimiteri | default('') }}" hidden></span>
{% if rows %}
{# US-011: form de stergere bulk. Selectia opereaza DOAR pe randuri blocate
{# Form de stergere bulk. Selectia opereaza DOAR pe randuri blocate
(gestionabil); sent/sending/queued nu au checkbox (read-only). #}
<form id="bulk-trimiteri"
hx-post="/trimiteri/sterge-bulk"
@@ -29,9 +43,8 @@
</tr></thead>
<tbody>
{% for r in rows %}
{# PRD 5.9 US-003: randul declanseaza deschiderea MODALULUI global (#detaliu-modal-body),
nu un rand-sibling. Clickabil/focusabil (role=button); Enter/Space deschid modalul
(JS in base.html). Vechiul rand-sibling de detaliu a fost eliminat. #}
{# Randul declanseaza deschiderea MODALULUI global (#detaliu-modal-body).
Clickabil/focusabil (role=button); Enter/Space deschid modalul (JS in base.html). #}
<tr id="trimitere-row-{{ r.id }}"
class="trimitere-row"
data-detaliu-id="{{ r.id }}"
@@ -51,8 +64,8 @@
<td class="col-id muted" data-eticheta="#">{{ r.id }}</td>
<td class="col-stare" data-eticheta="Stare">
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span>
{# PRD 5.9 US-002 (R1): eticheta umana scurta sub pill — text mic, `s-error`
pe error/needs_* (singurele stari pe care `eticheta_problema` e ne-goala).
{# Eticheta umana scurta sub pill — text mic, `s-error` pe error/needs_*
(singurele stari pe care `eticheta_problema` e ne-goala).
Stare transmisa prin TEXT, nu doar culoare. Codul brut ramane in modal. #}
{% if r.eticheta_problema %}
<div class="eticheta-problema s-error">{{ r.eticheta_problema }}</div>
@@ -61,13 +74,14 @@
<td class="col-vehicul" data-eticheta="Vehicul">
{{ r.prez.vehicul_nr }}
{% if r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
<span class="muted" style="font-size:12px;">{{ r.prez.vin_scurt }}</span>
{# VIN pe rand separat sub nr (element block, nu span inline) #}
<div class="muted" style="font-size:12px;">{{ r.prez.vin_scurt }}</div>
{% endif %}
</td>
<td class="col-operatie" data-eticheta="Operatie">
<div>{{ r.prez.operatie }}</div>
{# PRD 5.9 US-002: doar codul RAR (ex. OE-2), FARA prefixul "cod RAR:" — chip
muted discret; cand nemapat afiseaza "nemapat" muted (comportament 5.8). #}
{# Doar codul RAR (ex. OE-2), FARA prefixul "cod RAR:" — chip muted discret;
cand nemapat afiseaza "nemapat" muted. #}
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %}
<div class="cod-rar-sub"><span class="cod-rar-cod">{{ r.prez.cod_rar }}</span></div>
{% else %}
@@ -83,6 +97,105 @@
</table>
</div>
</form>
{#
Paginare numerotata.
Afisata doar cand exista mai mult de o pagina.
Fiecare link pastreaza filtrele curente (status, vehicul, data_de, data_pana).
Pagina curenta: aria-current="page" (semantic).
#}
{% if total is defined %}
<div aria-live="polite"
style="font-size:12px; color:var(--muted); text-align:right; margin-top:6px; margin-bottom:2px;">
{% if total == 0 %}
0 trimiteri
{% else %}
{{ page_start }}{{ page_end }} din {{ total }}
{% endif %}
</div>
{% endif %}
{% if pages is defined and pages > 1 %}
{#
Construim param-string pentru filtrele curente (fara page) — refolosit in fiecare link.
Filtrul status vine din pill-uri (nu din form); il pastram in URL.
#}
{% set pq = "" %}
{% if f_status %}{% set pq = pq + "&status=" + f_status %}{% endif %}
{% if f_vehicul %}{% set pq = pq + "&vehicul=" + f_vehicul %}{% endif %}
{% if f_data_de %}{% set pq = pq + "&data_de=" + f_data_de %}{% endif %}
{% if f_data_pana %}{% set pq = pq + "&data_pana=" + f_data_pana %}{% endif %}
<nav aria-label="Paginare trimiteri"
style="display:flex; justify-content:center; gap:4px; flex-wrap:wrap; margin-top:10px;">
{# Buton Anterior #}
{% if page > 1 %}
<button type="button"
hx-get="/_fragments/submissions?page={{ page - 1 }}{{ pq }}"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:pointer;
border:1px solid var(--line); background:var(--card); color:var(--fg);"
aria-label="Pagina anterioara">
&laquo;
</button>
{% else %}
<button type="button" disabled
style="padding:3px 10px; border-radius:6px; font-size:13px;
border:1px solid var(--line); background:var(--card); color:var(--muted);
opacity:0.4; cursor:default;"
aria-label="Pagina anterioara (indisponibila)">
&laquo;
</button>
{% endif %}
{# Numerele de pagina #}
{% for p in range(1, pages + 1) %}
{% if p == page %}
<button type="button"
aria-current="page"
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:default;
border:1px solid var(--accent); background:var(--accent); color:#fff;
font-weight:700;">
{{ p }}
</button>
{% else %}
<button type="button"
hx-get="/_fragments/submissions?page={{ p }}{{ pq }}"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:pointer;
border:1px solid var(--line); background:var(--card); color:var(--fg);">
{{ p }}
</button>
{% endif %}
{% endfor %}
{# Buton Urmator #}
{% if page < pages %}
<button type="button"
hx-get="/_fragments/submissions?page={{ page + 1 }}{{ pq }}"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:pointer;
border:1px solid var(--line); background:var(--card); color:var(--fg);"
aria-label="Pagina urmatoare">
&raquo;
</button>
{% else %}
<button type="button" disabled
style="padding:3px 10px; border-radius:6px; font-size:13px;
border:1px solid var(--line); background:var(--card); color:var(--muted);
opacity:0.4; cursor:default;"
aria-label="Pagina urmatoare (indisponibila)">
&raquo;
</button>
{% endif %}
</nav>
{% endif %}
{% elif filtru_activ %}
<div class="empty">
Nimic pe filtrul curent.

View File

@@ -1,14 +1,13 @@
{% from "_eroare.html" import card_erori %}
{% import '_macros.html' as ui %}
{# PRD 5.9 US-004: detaliu editabil in-place, butoane consolidate, ordine verticala R10.
Fragmentul se swap-uieste in corpul modalului global (#detaliu-modal-body). Heading-ul
poarta id-ul folosit de aria-labelledby al dialogului.
R9: operatie + cod RAR rezolvat apar IMPREUNA, read-only, folosind `prez.cod_rar`
{# Detaliu editabil in-place. Fragmentul se swap-uieste in corpul modalului global
(#detaliu-modal-body). Heading-ul poarta id-ul folosit de aria-labelledby al dialogului.
Operatie + cod RAR rezolvat apar IMPREUNA, read-only, folosind `prez.cod_rar`
(fallback „nemapat"), fara eticheta separata „Cod RAR". #}
{% set cod_afis = prez.cod_rar if (prez.cod_rar and prez.cod_rar != '—') else 'nemapat' %}
<div class="card" id="detaliu-card-{{ id }}" style="border:none; padding:0; margin:0;">
{# === R10 (1): header — #id + pill + motiv uman === #}
{# === Header — #id + pill + motiv uman === #}
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 8px;">
<h2 id="detaliu-modal-titlu" style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
<span class="pill {{ stare_css }}">{{ stare_text }}</span>
@@ -19,14 +18,16 @@
<p class="muted" style="margin:0 0 12px; font-size:13px;">{{ stare_subtext }}</p>
{% endif %}
{# === R10 (2): bloc eroare blocanta cand exista === #}
{% if erori_3n %}
{# === Bloc eroare blocanta — DOAR in read-only.
In editare, cardul 3-niveluri e inlocuit cu: erori per-camp in macro `camp`
(text simplu .s-error) + rezumat top-of-form pentru erori fara camp (mai jos). === #}
{% if not editabil and erori_3n %}
<div style="margin:0 0 14px;">
{{ card_erori(erori_3n) }}
</div>
{% endif %}
{# === R10 (3) + R9: mapare inline (PRD 5.7) — alege cod RAR pentru operatiile nemapate.
{# === Mapare inline — alege cod RAR pentru operatiile nemapate.
Cand nemapate_inline, linia „Operatie: X · nemapat" apare in formularul de mai jos
(cod_afis = nemapat), iar aici e picker-ul; dupa mapare, re-render arata codul rezolvat. === #}
{% if nemapate_inline %}
@@ -76,7 +77,7 @@
</div>
{% endif %}
{# === R10 (4): formular editabil (needs_data/needs_mapping) SAU context read-only.
{# === Formular editabil (needs_data/needs_mapping) SAU context read-only.
Zero dublare: campurile vehiculului apar O SINGURA DATA — editabile cand randul e
corectabil, altfel read-only. Operatie + cod RAR read-only deasupra campurilor. === #}
{% if editabil %}
@@ -88,6 +89,13 @@
{% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div>
{% endif %}
{# Erori fara camp (field None) nu dispar silentios in editare —
cardul 3n e ascuns, deci adaugam un rezumat simplu top-of-form.
Erori cu camp raman afisate per-camp de macro-ul `camp` de mai jos. #}
{% for e in erori_3n if not e.field %}
<div class="s-error" style="font-size:13px; margin:0 0 10px;" role="alert">{{ e.problema }}</div>
{% endfor %}
{% macro camp(nume, eticheta, valoare, tip='text') %}
<div style="margin-bottom:10px;">
<label for="c-{{ nume }}" class="muted" style="font-size:12px; display:block;">{{ eticheta }}</label>
@@ -105,11 +113,40 @@
hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{# Operatie + cod RAR read-only deasupra campurilor (R9, fara eticheta „Cod RAR"). #}
{# Select cod RAR pe stari editabile (needs_data/needs_mapping), cu nomenclator.
Read-only pe sent/sending/queued/error (nomenclator_rar gol → ramura else). #}
{% if nomenclator_rar %}
<div style="margin:0 0 12px;">
<label for="c-cod-prestatie" class="muted" style="font-size:12px; display:block;">Operatie RAR (cod prestatie)</label>
{% if prez.operatie and prez.operatie != '—' %}
<div class="muted" style="font-size:12px; margin-bottom:4px;">{{ prez.operatie }}</div>
{% endif %}
<select id="c-cod-prestatie" name="cod_prestatie" style="max-width:380px; width:100%;"
aria-label="Alege operatia RAR din nomenclator">
<option value="">— pastrat ({{ cod_afis }}) —</option>
{% for n in nomenclator_rar %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == cod_prestatie_curent %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
</select>
</div>
{% else %}
{# Operatie + cod RAR read-only deasupra campurilor (fara eticheta „Cod RAR"). #}
<div style="margin:0 0 12px;">
<div class="muted" style="font-size:12px;">Operatie</div>
<div>{{ prez.operatie }} &middot; {{ cod_afis }}</div>
</div>
{% endif %}
{# Operatie service (cod intern + denumire venita prin API/import), distinct de
operatia RAR mapata. op_service_cod="" cand lipseste → randul absent. #}
{% if prez.op_service_cod %}
<div style="margin:0 0 12px;">
<div class="muted" style="font-size:12px;">Operatie service</div>
<div>{{ prez.op_service_cod }}{% if prez.op_service_denumire %} — {{ prez.op_service_denumire }}{% endif %}</div>
</div>
{% endif %}
{# Nr. inmatriculare pe rand propriu, VIN dedesubt — ambele latime plina. #}
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr) }}
@@ -122,7 +159,7 @@
{{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial) }}
</div>
{# === R10 (5): actiune primara conditionata de stare (R2). needs_data/needs_mapping
{# === Actiune primara conditionata de stare. needs_data/needs_mapping
-> „Salveaza si retrimite" pe /corecteaza. UN SINGUR buton primar per stare. === #}
<div style="margin-top:14px;">
<button type="submit">Salveaza si retrimite</button>
@@ -139,25 +176,49 @@
<div style="word-break:break-all;">{{ prez.vin }}</div>
</div>
<div><div class="muted" style="font-size:12px;">Operatie</div><div>{{ prez.operatie }} &middot; {{ cod_afis }}</div></div>
{# Operatie service (cod intern + denumire), distinct de operatia RAR.
op_service_cod="" cand lipseste → randul absent (fara "—"). #}
{% if prez.op_service_cod %}
<div><div class="muted" style="font-size:12px;">Operatie service</div>
<div>{{ prez.op_service_cod }}{% if prez.op_service_denumire %} — {{ prez.op_service_denumire }}{% endif %}</div></div>
{% endif %}
<div><div class="muted" style="font-size:12px;">Data prestatie</div><div>{{ prez.data_prestatie }}</div></div>
<div><div class="muted" style="font-size:12px;">Odometru final</div><div>{{ prez.odometru }}</div></div>
</div>
{% endif %}
{# === R10 (5): actiuni de jos — primar Re-pune (doar error) + Sterge pe RAND SEPARAT (R2/R11) === #}
{# === Actiuni de jos — primar Re-pune (doar error) + Sterge pe RAND SEPARAT === #}
{% if status == 'error' or gestionabil %}
<div class="detaliu-actiuni-jos" style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);">
{# R2: error -> buton primar „Re-pune in coada" pe /repune (error nu e editabil). #}
{# Error -> buton primar „Re-pune in coada" pe /repune (error nu e editabil pentru #}
{# campuri vehicul, dar se poate schimba cod_prestatie prin acelasi formular). #}
{% if status == 'error' %}
<form hx-post="/trimitere/{{ id }}/repune"
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
hx-disabled-elt="find button" style="margin:0 0 10px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{# Select cod_prestatie optional in formularul /repune (doar pentru error). #}
{% if nomenclator_rar %}
<label for="cod-rar-error-{{ id }}" style="display:block; font-size:12px; color:var(--muted); margin-bottom:4px;">
Operatie RAR (optional — schimba codul si re-pune)
</label>
<select id="cod-rar-error-{{ id }}" name="cod_prestatie"
aria-label="Alege operatia RAR din nomenclator"
style="width:100%; margin-bottom:8px; font-size:13px;">
<option value="">— pastrat ({{ cod_prestatie_curent }}) —</option>
{% for item in nomenclator_rar %}
<option value="{{ item.cod_prestatie }}"
{% if item.cod_prestatie == cod_prestatie_curent %}selected{% endif %}>
{{ item.cod_prestatie }} — {{ item.nome_prestatie }}
</option>
{% endfor %}
</select>
{% endif %}
<button type="submit">Re-pune in coada</button>
</form>
{% endif %}
{# R11: UN SINGUR Sterge, outline distructiv (var(--err)), pe rand separat, full-width pe mobil. #}
{# UN SINGUR Sterge, outline distructiv (var(--err)), pe rand separat, full-width pe mobil. #}
{% if gestionabil %}
<form hx-post="/trimitere/{{ id }}/sterge"
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
@@ -173,7 +234,7 @@
</div>
{% endif %}
{# === R10 (6): Detalii tehnice — colapsat implicit === #}
{# === Detalii tehnice — colapsat implicit === #}
<details style="margin-top:14px;">
<summary class="muted" style="font-size:12px; cursor:pointer;">Detalii tehnice</summary>
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:10px 24px; margin-top:10px;">
@@ -195,7 +256,6 @@
{% endif %}
</details>
</div>
{# PRD 5.9 US-004 (R4): scriptul inline vechi (marcheazaDetaliuDeschis / scrollIntoView pe
randul-sibling) a fost eliminat de US-003. Focus-ul post-swap (incl. re-render corectie/
mapare) e gestionat de htmx:afterSettle pe #detaliu-modal-body din base.html. R5: inchiderea
modalului pe succes (queued/sterge) vine din HX-Trigger `inchideModal` emis de rute. #}
{# Focus-ul post-swap (incl. re-render corectie/mapare) e gestionat de htmx:afterSettle pe
#detaliu-modal-body din base.html. Inchiderea modalului pe succes (queued/sterge) vine
din HX-Trigger `inchideModal` emis de rute. #}

View File

@@ -1,7 +1,7 @@
<div id="import-section">
{% set pas = 1 %}{% include '_stepper.html' %}
{# US-004 (3.6): bara de upload accentuata (border de accent) ca sa ramana punctul
de intrare evident chiar cu tabelul Trimiteri lung dedesubt (D-1.1/D-5.2). #}
{# Bara de upload accentuata (border de accent) ca sa ramana punctul
de intrare evident chiar cu tabelul Trimiteri lung dedesubt. #}
{% from '_eroare.html' import card_erori %}
<div class="card" style="border-color:var(--accent);">
@@ -105,7 +105,7 @@
var dz = document.getElementById('drop-zone');
var frm = document.getElementById('upload-form');
/* US-003 (3.6): un singur sticky bar pe ecran — cand re-apare zona de upload
/* Un singur sticky bar pe ecran — cand re-apare zona de upload
(reset sau dupa commit), sectiunea Trimiteri redevine vizibila. */
var trim = document.getElementById('trimiteri-section');
if (trim) trim.style.display = '';

View File

@@ -2,7 +2,7 @@
{% block title %}Conturi clienti — Gateway RAR AUTOPASS{% endblock %}
{% block content %}
{# US-009 (5.5): metadate verbe de ciclu de viata (eticheta, ruta, clasa). #}
{# Metadate verbe de ciclu de viata (eticheta, ruta, clasa). #}
{% set VERBS = {
'activate': ('Activeaza', '/admin/activate', ''),
'block': ('Blocheaza', '/admin/block', ''),

View File

@@ -6,7 +6,7 @@
<title>{% block title %}Gateway RAR AUTOPASS{% endblock %}</title>
<script src="/static/htmx.min.js"></script>
<script>
// US-002 (3.6): raspunsurile de editare-rand contin un <tr> (swap pe rand) PLUS
// Raspunsurile de editare-rand contin un <tr> (swap pe rand) PLUS
// elemente OOB non-rand (#preview-rezumat, #preview-ok-count). Fara fragmente-template,
// htmx parseaza raspunsul care incepe cu <tr> in context de tabel (<table><tbody>) si
// "foster-parent"-eaza div/span-urile OOB afara din fragment -> swapError + contoare pierdute.
@@ -14,12 +14,16 @@
htmx.config.useTemplateFragments = true;
</script>
<script>
// Anti-FOUC (US-001 PRD 5.3): citeste preferinta tema din localStorage inainte de
// primul paint; seteaza data-theme pe <html> sincron, fara blink dark->light.
// Anti-FOUC: citeste preferinta tema din localStorage inainte de primul
// paint; seteaza data-theme pe <html> sincron, fara blink.
// Cunoaste toate cele 4 teme: light/dark/petrol/auto. Valoare legacy/necunoscuta -> auto.
// 'auto' se rezolva la 'light' sau 'dark' dupa prefers-color-scheme (fara blink).
(function() {
var VALID = {light:1, dark:1, petrol:1, auto:1};
try {
var t = localStorage.getItem('theme');
if (!t) {
if (!t || !VALID[t]) t = 'auto'; // fallback: valoare lipsa sau legacy -> auto
if (t === 'auto') {
t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
document.documentElement.setAttribute('data-theme', t);
@@ -29,19 +33,100 @@
})();
</script>
<style>
:root { --bg:#0f1115; --card:#181b22; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36;
--ok:#3ecf8e; --warn:#e6b34a; --err:#e5605e; --accent:#5b8def; }
[data-theme="light"] { --bg:#f6f7f9; --card:#ffffff; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea;
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#2563eb; }
/* IBM Plex Sans + Mono self-hosted (latin-ext pentru diacritice romanesti).
font-display:swap permite text vizibil inainte de incarcare (FOUT system-ui->IBM Plex);
reflow-ul vizibil pe VIN/coduri e acceptat explicit. */
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/static/fonts/IBMPlexSans-Regular-latin-ext.woff2") format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
}
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/static/fonts/IBMPlexSans-Regular-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
}
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/static/fonts/IBMPlexSans-Medium-latin-ext.woff2") format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
}
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/static/fonts/IBMPlexSans-Medium-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
}
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/static/fonts/IBMPlexSans-Bold-latin-ext.woff2") format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
}
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/static/fonts/IBMPlexSans-Bold-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/static/fonts/IBMPlexMono-Regular-latin-ext.woff2") format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/static/fonts/IBMPlexMono-Regular-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
}
/* Paleta dark (default) — accent azur ROMFAST */
:root { --bg:#0f1218; --card:#181c24; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36;
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6; }
/* Paleta light — accent azur inchis pentru contrast AA pe alb (#1F66C9: 5.51:1 pe alb) */
[data-theme="light"] { --bg:#f5f7fa; --card:#ffffff; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea;
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#1F66C9; }
/* Paleta Petrol — tema intunecata alternativa, accent teal #0E7C7B.
Wordmark-ul FAST #2E74D6 coexista armonios: ambele sunt reci/saturate, contrast AA pe --card #161e20. */
[data-theme="petrol"] { --bg:#0e1416; --card:#161e20; --ink:#e6e9ef; --muted:#8b93a7; --line:#232c2e;
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#0E7C7B; }
* { box-sizing:border-box; }
/* PRD 5.9 US-006 — CONVENTIE BREAKPOINT: un singur prag mobil la 768px.
/* CONVENTIE BREAKPOINT: un singur prag mobil la 768px.
CSS custom properties NU functioneaza in `@media`, deci pragul nu poate fi o
variabila; folosim consecvent `@media (max-width:767px)` peste tot (mobil) si
`@media (max-width:1024px)` doar pentru densitatea tabelului. >=1024px = layout
desktop neschimbat (fara regresie). Orice regula mobila noua reutilizeaza 767px. */
body { margin:0; font:15px/1.5 ui-sans-serif,system-ui,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
body { margin:0; font:15px/1.5 "IBM Plex Sans",system-ui,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
background:var(--bg); color:var(--ink); -webkit-font-smoothing:antialiased; }
header { padding:16px 24px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:12px; }
/* Grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). */
header { padding:16px 24px; border-bottom:1px solid var(--line);
display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:8px; min-height:92px; }
.header-left { display:flex; align-items:center; }
.header-center { display:flex; flex-direction:column; align-items:center; text-align:center; }
.header-right { display:flex; align-items:center; justify-content:flex-end; gap:8px; }
/* Logo ROMFAST aliniat stanga; transparent, ok pe dark/light/petrol fara filtre de culoare. */
.brand-logo { height:60px; width:auto; display:block; margin:0; }
.header-center .env { font-size:11px; margin-top:2px; }
header h1 { font-size:20px; margin:0; font-weight:700; letter-spacing:-.01em; }
header .env { font-size:12px; color:var(--muted); border:1px solid var(--line); padding:2px 8px; border-radius:99px; }
main { padding:24px; max-width:1100px; margin:0 auto; }
@@ -56,6 +141,28 @@
th { color:var(--muted); font-weight:500; font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
.empty { color:var(--muted); padding:24px; text-align:center; }
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid var(--line); }
/* Pill-uri de filtrare a starii (bara de filtre Trimiteri). Inactiv = contur+text pe
culoarea categoriei (injectata inline); activ = umplere pe acea culoare. */
.pills-categorii { display:inline-flex; gap:8px; flex-wrap:wrap; align-items:center; }
.pill-cat { display:inline-flex; align-items:center; gap:5px; padding:4px 11px; border-radius:99px;
font-size:12px; font-weight:600; cursor:pointer; background:transparent;
border:1.5px solid var(--line); color:var(--muted); min-height:30px;
transition:background .15s, color .15s; }
.pill-cat:hover { filter:brightness(1.1); }
.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;
border-radius:99px; min-width:18px; text-align:center; }
.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-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
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-error,.s-needs_data,.s-needs_mapping{color:var(--err);}
.s-ok{color:var(--ok);}
@@ -94,7 +201,7 @@
button { background:var(--accent); border-color:var(--accent); color:#fff; cursor:pointer; }
button:hover { filter:brightness(1.08); }
.chk { font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px; }
/* Tab-bar (US-003) */
/* Tab-bar */
.tab-bar { display:flex; gap:2px; overflow-x:auto; -webkit-overflow-scrolling:touch;
border-bottom:1px solid var(--line); margin-bottom:16px; padding-bottom:0;
scrollbar-width:none; }
@@ -108,25 +215,30 @@
border-color:var(--line); border-bottom-color:var(--card); }
.tab-panel { min-height:120px; }
.status-bar { margin-bottom:12px; }
/* Eroare 3 niveluri (US-006, PRD 5.4) */
/* Eroare 3 niveluri */
.eroare-3n { margin-top:10px; }
.eroare-3n-item { padding:8px 10px; border-left:3px solid var(--err);
background:color-mix(in srgb, var(--err) 8%, var(--card));
border-radius:0 6px 6px 0; }
.eroare-3n-sep { margin-top:6px; }
.eroare-3n-problema { font-weight:600; color:var(--err); font-size:13px; }
.eroare-3n-camp { font-family:monospace; font-size:12px; opacity:.85; }
.eroare-3n-camp { font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:12px; opacity:.85; }
.eroare-3n-cauza { color:var(--muted); font-size:12px; margin-top:3px; }
.eroare-3n-fix { color:var(--accent); font-size:12px; margin-top:3px; }
.eroare-3n-label { font-weight:500; }
/* Inline fix per camp in preview */
.camp-fix { color:var(--accent); font-size:11px; margin-top:2px; display:block; }
/* Meniu hamburger cont (US-006 PRD 5.5) — dropdown ancorat dreapta-sus */
/* Meniu hamburger cont — dropdown ancorat dreapta-sus */
.cont-menu-wrap { position:relative; }
.icon-btn { background:transparent; border:1px solid var(--line); color:var(--ink); cursor:pointer;
border-radius:6px; min-height:36px; min-width:36px; font-size:16px; padding:4px 8px;
line-height:1; display:inline-flex; align-items:center; justify-content:center; }
.icon-btn:hover { background:var(--line); }
/* Variante icon-btn — dirty (modificari nesalvate) + danger (destructiv) */
.icon-btn.dirty { background:var(--accent); color:#fff; border-color:var(--accent); }
.icon-btn.dirty:hover { filter:brightness(0.9); }
.icon-btn.danger { color:var(--err); border-color:var(--err); }
.icon-btn.danger:hover, .icon-btn.danger:focus-visible { background:var(--err); color:#fff; }
.cont-menu { position:absolute; right:0; top:calc(100% + 8px); min-width:180px; z-index:50;
background:var(--card); border:1px solid var(--line); border-radius:8px; padding:6px;
box-shadow:0 8px 24px rgba(0,0,0,.18); display:flex; flex-direction:column; gap:2px; }
@@ -139,7 +251,7 @@
.cont-menu form { margin:0; }
/* Kebab partajat (actiuni per-rand in tabele). Meniul e position:fixed si pozitionat de JS:
altfel `.tablewrap { overflow-x:auto }` induce overflow-y:auto si TAIE dropdown-ul pe ultimul
rand (bug 5.5 — meniul nu se vedea). fixed scoate meniul din contextul de clipping al tabelului. */
rand. fixed scoate meniul din contextul de clipping al tabelului. */
.kebab { position:relative; display:inline-block; }
.kebab > summary { list-style:none; cursor:pointer; display:inline-flex; align-items:center;
justify-content:center; min-height:32px; min-width:32px; padding:4px 10px;
@@ -167,7 +279,7 @@
.dt-pager button { background:transparent; color:var(--ink); border:1px solid var(--line);
padding:5px 12px; min-height:32px; }
.dt-pager button:disabled { opacity:.45; cursor:default; }
/* === Tabel trimiteri (PRD 5.8 US-007): fara scroll orizontal. SCOPAT prin
/* === Tabel trimiteri: fara scroll orizontal. SCOPAT prin
.tabel-trimiteri ca sa NU strice celelalte tabele (.tablewrap e partajat de
Mapari/Formate). Permitem wrap controlat pe coloanele text + latimi rezonabile. === */
.tabel-trimiteri table { table-layout:fixed; }
@@ -181,16 +293,15 @@
.tabel-trimiteri .col-operatie > div { line-height:1.35; }
/* secundarul muted („cod RAR" / „nemapat") — >=12px, contrast pe var(--muted) >=4.5:1 */
.tabel-trimiteri .cod-rar-sub { font-size:12px; margin-top:2px; }
/* PRD 5.9 US-002: codul RAR pe linia 2 — chip discret, fara prefixul „cod RAR:". */
.tabel-trimiteri .cod-rar-cod { display:inline-block; font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
/* Codul RAR pe linia 2 — chip discret, fara prefixul „cod RAR:". */
.tabel-trimiteri .cod-rar-cod { display:inline-block; font-family:"IBM Plex Mono",ui-monospace,monospace;
font-size:12px; padding:1px 7px; border:1px solid var(--line);
border-radius:99px; color:var(--muted); }
/* PRD 5.9 US-002 (R1): eticheta umana scurta sub pill — text mic; clasa `s-error`
o coloreaza (apare doar pe error/needs_*). Stare prin text, nu doar culoare. */
/* Eticheta umana scurta sub pill — text mic; clasa `s-error` o coloreaza
(apare doar pe error/needs_*). Stare prin text, nu doar culoare. */
.tabel-trimiteri .eticheta-problema { font-size:12px; line-height:1.3; margin-top:3px; }
/* PRD 5.9 US-002 (R8): randul e clickabil (deschide modalul) -> tinta de atins >=44px
(touch) + afordanta hover/focus. Inlocuieste vechea regula `@media pointer:coarse
.chevron` (chevron eliminat); este SINGURA regula 44px pe rand. */
/* Randul e clickabil (deschide modalul) -> tinta de atins >=44px (touch) +
afordanta hover/focus. */
.tabel-trimiteri tr.trimitere-row { min-height:44px; }
.tabel-trimiteri tr.trimitere-row > td { padding-top:11px; padding-bottom:11px; }
.tabel-trimiteri tr.trimitere-row:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
@@ -200,10 +311,10 @@
@media (max-width:1024px) {
.tabel-trimiteri .col-actualizat { display:none; }
}
/* === Modal detaliu (PRD 5.9 US-003): fereastra modala globala, in afara zonei de
poll (#submissions-wrap). Backdrop + dialog centrat pe desktop; focus-trap +
/* === Modal detaliu: fereastra modala globala, in afara zonei de poll
(#submissions-wrap). Backdrop + dialog centrat pe desktop; focus-trap +
scroll-lock + inert pe <main> sunt in JS. Varianta full-screen mobil: vezi blocul
`@media (max-width:767px)` US-006 de mai jos. === */
`@media (max-width:767px)` de mai jos. === */
.modal-overlay { position:fixed; inset:0; z-index:1100; display:flex;
align-items:flex-start; justify-content:center; padding:40px 16px; overflow-y:auto; }
.modal-overlay[hidden] { display:none; }
@@ -220,9 +331,9 @@
body.modal-open { overflow:hidden; }
.modal-eroare { padding:16px 4px; }
.modal-eroare .actiuni { margin-top:12px; display:flex; gap:10px; flex-wrap:wrap; }
/* === PRD 5.9 US-006: fundatie responsive mobil (<768px) ===
/* === Fundatie responsive mobil (<768px) ===
Breakpoint unic 767px (vezi conventia de sus). Cuprinde: card per rand pe tabelul
de trimiteri (5.8, pastrat), modal full-screen, header/nav colapsat cu tinte touch
de trimiteri, modal full-screen, header/nav colapsat cu tinte touch
>=44px. Desktop (>=1024px) ramane neschimbat — regulile de baza nu se modifica. */
@media (max-width:767px) {
/* Tabel trimiteri: card per rand (eticheta:valoare stivuit) -> fara scroll orizontal */
@@ -245,21 +356,26 @@
padding:16px; padding-top:56px; overflow-y:auto; }
.modal-close { width:44px; height:44px; top:8px; right:8px; font-size:24px; }
/* US-004 (R11): actiunile de jos din detaliu (Re-pune / Sterge) full-width stivuit pe mobil. */
/* Actiunile de jos din detaliu (Re-pune / Sterge) full-width stivuit pe mobil. */
.detaliu-actiuni-jos button { width:100%; }
/* Header + nav colapsate: header se rupe pe linii, fara scroll orizontal de pagina;
tintele touch (toggle tema/cont, taburi, itemi meniu cont) cresc la >=44px. */
header { padding:12px 16px; flex-wrap:wrap; gap:8px; }
/* Header + nav colapsate: pe mobil trece de la grid la flex wrap.
Randul 1: [logo ROMFAST stanga] [controale dreapta] (margin-left:auto pe .header-right).
Randul 2: [titlu + env mic centrat, full-width]. Fara scroll orizontal, tinte >=44px. */
header { display:flex; flex-wrap:wrap; padding:12px 16px; gap:8px; align-items:center; min-height:0; }
.brand-logo { height:44px; }
.header-left { order:0; flex:0 0 auto; }
.header-center { order:2; width:100%; text-align:center; }
.header-right { order:1; margin-left:auto; flex:0 0 auto; }
header h1 { font-size:17px; }
main { padding:16px; }
.icon-btn { min-height:44px; min-width:44px; }
.tab-link { min-height:44px; padding:10px 14px; }
.cont-menu a, .cont-menu button { min-height:44px; }
/* === PRD 5.9 US-007 (R12): paginile de continut pe mobil ===
/* === Paginile de continut pe mobil ===
Tabele ACTIONABILE (Mapari) -> card per rand. Clasa proprie `.tabel-card`,
scopata SEPARAT de `.tabel-trimiteri` (5.8) ca sa NU strice cardurile de
scopata SEPARAT de `.tabel-trimiteri` ca sa NU strice cardurile de
trimiteri. Tabele DENSE read-only (Jurnal, Nomenclator) + Admin raman in
`.tablewrap` (scroll orizontal CONTAINED, definit global mai sus). */
.tabel-card table { table-layout:auto; }
@@ -291,11 +407,11 @@
#card-cont button, #form-test-cheie button,
#jurnal-section #filtre-jurnal button { min-height:44px; width:100%; }
/* === PRD 5.9 US-008: Acasa (upload, status, filtre) + login/signup pe mobil ===
/* === Acasa (upload, status, filtre) + login/signup pe mobil ===
Zona de upload, bara de status si bara de filtre (`_coada.html`) stiveaza pe O
coloana sub 767px; inputuri/butoane full-width cu tinta touch >=44px. Scopat pe
id-urile sectiunilor de pe Acasa ca sa NU atinga tabelul de trimiteri (5.8),
modalul sau paginile de continut (US-007). */
id-urile sectiunilor de pe Acasa ca sa NU atinga tabelul de trimiteri,
modalul sau paginile de continut. */
/* Bara de upload: zona slim (returning user) trece pe coloana; butonul full-width. */
#import-section .drop-zone { flex-direction:column; align-items:stretch; text-align:left; }
#import-section #upload-btn { width:100%; min-height:44px; }
@@ -315,22 +431,36 @@
</style>
</head>
<body>
{# Grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). #}
<header>
<h1>Gateway RAR AUTOPASS</h1>
<span class="env">{{ rar_env }}</span>
<div style="margin-left:auto; display:flex; align-items:center; gap:8px;">
{# Celula stanga: logo ROMFAST #}
<div class="header-left">
{# Logo PNG real, RGBA transparent — ok pe toate temele fara filtre. #}
<img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
</div>
{# Celula centru: titlu + badge env mic #}
<div class="header-center">
<h1>Gateway RAR AUTOPASS</h1>
<span class="env">{{ rar_env }}</span>
</div>
{# Celula dreapta: comutator tema + versiune + meniu cont #}
<div class="header-right">
<button id="tema-toggle" class="icon-btn"
aria-label="Comuta tema (luminos/intunecat)"
title="Comuta tema">&#9728;</button>
<span class="muted" style="font-size:13px;">v{{ version }}</span>
{% if is_authenticated|default(false) %}
{# Meniu cont (US-006 PRD 5.5): Cont/Integrare/Nomenclator + (admin) + logout.
{# Meniu cont: Cont/Integrare/Nomenclator + (admin) + logout.
Pe paginile neautentificate (login/signup) nu se randeaza deloc. #}
<div class="cont-menu-wrap">
<button id="cont-menu-toggle" class="icon-btn"
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
aria-label="Meniu cont" title="Meniu cont">&#9776;</button>
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden>
{# Mapari, cu badge needs_mapping. #}
{% 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>
<hr>
<a role="menuitem" href="/?tab=cont">Cont</a>
<a role="menuitem" href="/?tab=integrare">Integrare</a>
<a role="menuitem" href="/?tab=nomenclator">Nomenclator</a>
@@ -346,11 +476,14 @@
{% endif %}
</div>
</header>
{# aria-live pentru anuntarea schimbarilor de tema (accesibilitate) #}
<span id="tema-live" role="status" aria-live="polite"
style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;"></span>
<main>{% block content %}{% endblock %}</main>
{# Modal detaliu trimitere (PRD 5.9 US-003): container global, SIBLING al <main>
(nu descendent), ca `inert`+`aria-hidden` pe <main> sa nu-l prinda si pe el (R7).
Corpul #detaliu-modal-body e tinta de swap pentru fragment + rutele corectie/
mapare/lifecycle. Traieste in afara #submissions-wrap -> poll-ul de 15s nu-l atinge. #}
{# Modal detaliu trimitere: container global, SIBLING al <main> (nu descendent),
ca `inert`+`aria-hidden` pe <main> sa nu-l prinda si pe el. Corpul
#detaliu-modal-body e tinta de swap pentru fragment + rutele corectie/mapare/
lifecycle. Traieste in afara #submissions-wrap -> poll-ul nu-l atinge. #}
<div id="modal-detaliu" class="modal-overlay" role="dialog" aria-modal="true"
aria-labelledby="detaliu-modal-titlu" hidden>
<div class="modal-backdrop" data-modal-close></div>
@@ -360,41 +493,49 @@
</div>
</div>
<script>
// Handler comutator tema (US-002 PRD 5.3): click toggle light<->dark, persista in localStorage.
// Separare init (doar sincronizare iconita) de persistenta (doar la click explicit).
// Motivatie: scrierea in localStorage la init ar ingloba imediat preferinta OS-aware ca alegere
// explicita, impiedicand urmarirea ulterioara a modificarilor de tema ale sistemului de operare.
// Comutator tema ciclic: click cicleaza Light->Dark->Petrol->Auto.
// Separare init (sincronizare iconita/label) de persistenta (doar la click explicit).
// 'auto' se rezolva la paint prin anti-FOUC; aici setam data-theme rezolvat.
(function() {
var btn = document.getElementById('tema-toggle');
if (!btn) return;
// Sincronizeaza iconita si aria-label dupa tema curenta -- FARA efecte secundare in localStorage.
function _syncIcon(t) {
if (t === 'light') {
btn.innerHTML = '&#9790;';
btn.setAttribute('aria-label', 'Comuta tema (intunecat)');
btn.title = 'Comuta tema (intunecat)';
} else {
btn.innerHTML = '&#9728;';
btn.setAttribute('aria-label', 'Comuta tema (luminos)');
btn.title = 'Comuta tema (luminos)';
}
var CYCLE = ['light', 'dark', 'petrol', 'auto'];
var VALID = {light:1, dark:1, petrol:1, auto:1};
// Iconite per tema: ☀ Light, ☾ Dark, ◐ Petrol, ◉ Auto
var ICONS = {light:'&#9728;', dark:'&#9790;', petrol:'&#9680;', auto:'&#9689;'};
var LABELS = {light:'Light', dark:'Dark', petrol:'Petrol', auto:'Auto'};
var NEXT = {light:'Dark', dark:'Petrol', petrol:'Auto', auto:'Light'};
function _stored() {
try { var v = localStorage.getItem('theme'); return (v && VALID[v]) ? v : 'auto'; } catch(e) { return 'auto'; }
}
function _resolved(stored) {
if (stored !== 'auto') return stored;
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
function _syncButton(stored) {
var s = VALID[stored] ? stored : 'auto';
btn.innerHTML = ICONS[s];
btn.setAttribute('aria-label', 'Tema: ' + LABELS[s] + ', apasa pentru ' + NEXT[s]);
btn.title = LABELS[s]; // doar numele temei (ex. "Petrol"), nu ciclul intreg
}
// Aplica o tema noua, seteaza data-theme si persista in localStorage -- apelat DOAR la click.
function _setTheme(t) {
document.documentElement.setAttribute('data-theme', t);
document.documentElement.setAttribute('data-theme', _resolved(t));
try { localStorage.setItem('theme', t); } catch(e) {}
_syncIcon(t);
_syncButton(t);
var live = document.getElementById('tema-live');
if (live) live.textContent = 'Tema: ' + LABELS[t] + (t === 'auto' ? ' (urmeaza sistemul)' : '');
}
// La init: sincronizeaza doar iconita din data-theme curent (setat deja de scriptul anti-FOUC).
_syncIcon(document.documentElement.getAttribute('data-theme') || 'dark');
// Init: sincronizeaza iconita din starea stocata (fara a scrie in localStorage).
_syncButton(_stored());
btn.addEventListener('click', function() {
var cur = document.documentElement.getAttribute('data-theme') || 'dark';
_setTheme(cur === 'dark' ? 'light' : 'dark');
var cur = _stored();
var idx = CYCLE.indexOf(cur);
_setTheme(CYCLE[(idx + 1) % CYCLE.length]);
});
})();
</script>
<script>
// Meniu cont (US-006 PRD 5.5): dropdown ancorat dreapta-sus. Deschide/inchide la click,
// Meniu cont: dropdown ancorat dreapta-sus. Deschide/inchide la click,
// inchide la Esc (focus readus pe buton) si la click in afara. Fara dependente.
(function() {
var toggle = document.getElementById('cont-menu-toggle');
@@ -470,6 +611,19 @@
window.addEventListener('resize', function() { closeAll(null); });
})();
</script>
<script>
// Dirty state pentru butoanele de salvare din tabelele de mapari.
// Cand utilizatorul schimba un select dintr-un form de mapare, butonul de salvare
// legat prin data-dirty-form devine evidentiat (clasa "dirty" → fundal --accent).
// Starea "dirty" e efemera per-render: un swap outerHTML o reseteaza automat.
// Delegare pe document → supravietuieste swap-urilor HTMX (#mapari-section).
document.addEventListener('change', function(e) {
var el = e.target;
if (el.tagName !== 'SELECT' || !el.form || !el.form.id) return;
var saveBtn = document.querySelector('button[data-dirty-form="' + el.form.id + '"]');
if (saveBtn) saveBtn.classList.add('dirty');
});
</script>
<script>
// Cautare + paginare client-side pentru tabele mari (data-dt="<page_size>"). Filtreaza si
// pagineaza DOM-ul deja randat (fara cereri server) — potrivit pentru maparile care pot creste
@@ -534,11 +688,11 @@
})();
</script>
<script>
// Modal detaliu trimitere (PRD 5.9 US-003): inlocuieste detaliul inline (5.8). Detaliul
// se incarca prin HTMX in #detaliu-modal-body (in afara #submissions-wrap, deci poll-ul
// de 15s nu-l atinge). Aici: deschidere la click pe rand, inchidere (x/Esc/backdrop),
// focus-trap, scroll-lock, inert+aria-hidden pe <main> (R7), stare de eroare la load
// esuat (R5), inchidere pe succes corectie/sterge (HX-Trigger inchideModal, R5).
// Modal detaliu trimitere: detaliul se incarca prin HTMX in #detaliu-modal-body
// (in afara #submissions-wrap, deci poll-ul nu-l atinge). Aici: deschidere la click
// pe rand, inchidere (x/Esc/backdrop), focus-trap, scroll-lock, inert+aria-hidden pe
// <main>, stare de eroare la load esuat, inchidere pe succes corectie/sterge
// (HX-Trigger inchideModal).
(function() {
var overlay = document.getElementById('modal-detaliu');
if (!overlay) return;
@@ -555,7 +709,7 @@
' select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'),
function(el) { return el.offsetParent !== null || el === document.activeElement; });
}
// R7: focus-trap — Tab/Shift+Tab cicleaza in interiorul dialogului.
// focus-trap — Tab/Shift+Tab cicleaza in interiorul dialogului.
function trapFocus(e) {
if (e.key !== 'Tab') return;
var f = focusable();
@@ -589,7 +743,6 @@
if (t && t.focus) t.focus(); // focus readus pe rand
}
// API public: butonul „Inchide" din fragment + inchiderea pe succes corectie/sterge.
// (Semnatura veche inchideDetaliu(id) pastrata, dar exista un singur modal o data.)
window.inchideDetaliu = function() { close(); };
// Inchidere: x si backdrop (elemente cu data-modal-close), Esc.
@@ -612,7 +765,7 @@
var f = focusable();
if (f.length) f[0].focus();
});
// R5: load-error al fragmentului (GET esuat) -> stare Reincearca/Inchide, nu placeholder blocat.
// Load-error al fragmentului (GET esuat) -> stare Reincearca/Inchide, nu placeholder blocat.
body.addEventListener('htmx:responseError', function(evt) {
if (!isOpen()) return;
var elt = evt.detail && evt.detail.elt;
@@ -630,7 +783,7 @@
{ target: body, swap: 'innerHTML' });
});
// R5: inchidere pe succes corectie/sterge — ruta emite HX-Trigger `inchideModal`.
// Inchidere pe succes corectie/sterge — ruta emite HX-Trigger `inchideModal`.
// Lista se reincarca separat prin `trimiteriChanged` (#submissions-wrap). Maparea
// inline NU emite inchideModal -> modalul ramane deschis sa arate codul rezolvat.
document.body.addEventListener('inchideModal', function() { close(); });
@@ -647,45 +800,49 @@
})();
</script>
<script>
// Poll-guard (PRD 5.9 US-005, R6). Inlocuieste vechea pauza pe „rand expandat" (5.8):
// randul-sibling de detaliu nu mai exista (US-003 l-a mutat in modalul global, care
// traieste in afara #submissions-wrap -> un swap de poll nu-l atinge). Aici oprim
// poll-ul de 15s de a REINCARCA lista cat timp (a) modalul e deschis SAU (b) exista
// cel putin un checkbox de bulk bifat — altfel modalul s-ar reseta / bifele s-ar sterge.
//
// CRITIC (F5): blocam DOAR trigger-ul periodic. In htmx `load`/`every 15s` declanseaza
// requestul FARA `triggeringEvent`; `trimiteriChanged` (HX-Trigger dupa corectie/stergere)
// si submit-ul/filtrul AU `triggeringEvent` -> TREC MEREU. Asa evitam blocajul permanent:
// daca randul bifat paraseste filtrul, pauza nu ramane lipita (pauza e legata strict de
// trigger-ul periodic, nu de o stare „sticky"). Anularea unui `htmx:beforeRequest` NU
// opreste timer-ul htmx (se reprogrameaza singur) -> poll-ul reia automat la urmatorul
// tic cand ambele conditii dispar; nu se pierde scroll, focus sau selectia de bife.
// Filtrare stare prin pill-uri + reincarcare manuala a tabelului. Tabelul NU se mai
// schimba singur (fara poll periodic pe #submissions-wrap): un poller usor verifica
// 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 modalDeschis() {
var o = document.getElementById('modal-detaliu');
return !!(o && !o.hidden);
// Pill de stare: scrie campul hidden, reseteaza pagina la 1 si re-trimite filtrul.
window.filtreazaStare = function(btn, status) {
var form = document.getElementById('filtre-trimiteri');
if (!form) return;
var hs = document.getElementById('f-status'); if (hs) hs.value = status || '';
var hp = document.getElementById('f-page'); if (hp) hp.value = '1';
document.querySelectorAll('#pills-categorii .pill-cat').forEach(function(b) {
b.setAttribute('aria-pressed', 'false');
});
if (btn) btn.setAttribute('aria-pressed', 'true');
if (form.requestSubmit) form.requestSubmit(); else form.submit();
};
// Reincarca tabelul pastrand filtrul curent (hx-include #filtre-trimiteri) si ascunde nudge-ul.
window.reincarcaTrimiteri = function() {
var n = document.getElementById('nudge-trimiteri'); if (n) n.hidden = true;
if (window.htmx) htmx.trigger('#submissions-wrap', 'reincarcaTrimiteri');
};
// Poller "Date noi": 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.
var INTERVAL = 20000;
function versiuneCurenta() {
var e = document.getElementById('trimiteri-versiune');
return e ? e.getAttribute('data-v') : null;
}
function existaBifa() {
return !!document.querySelector('#submissions-wrap input[name="submission_id"]:checked');
function verifica() {
if (versiuneCurenta() === null) return; // tabelul nu e pe ecran (alt tab)
var nudge = document.getElementById('nudge-trimiteri');
if (!nudge || !nudge.hidden) return; // deja afisat -> nu re-cere
fetch('/_fragments/trimiteri-versiune', { headers: { 'X-Requested-With': 'fetch' } })
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(d) {
if (!d) return;
if (d.v !== versiuneCurenta()) nudge.hidden = false;
})
.catch(function() {});
}
document.body.addEventListener('htmx:beforeRequest', function(evt) {
var d = evt.detail || {};
if (!d.elt || d.elt.id !== 'submissions-wrap') return; // doar poll-ul listei
var rc = d.requestConfig || {};
if (rc.triggeringEvent) return; // trimiteriChanged / filtru: TREC MEREU
if (modalDeschis() || existaBifa()) evt.preventDefault(); // pauza scopata pe periodic
});
// Resume pe checkbox `change`->gol: delegare pe body ca sa prinda si checkbox-urile
// randate dupa swap. Cand modalul e inchis si nu mai exista nicio bifa, fortam un
// refresh imediat (nu mai asteptam ticul de 15s) prin `trimiteriChanged from:body`,
// care pastreaza filtrul curent (hx-include #filtre-trimiteri) si trece de guard.
document.body.addEventListener('change', function(evt) {
var t = evt.target;
if (!(t && t.name === 'submission_id')) return;
if (!modalDeschis() && !existaBifa() && window.htmx) {
htmx.trigger(document.body, 'trimiteriChanged');
}
});
setInterval(verifica, INTERVAL);
})();
</script>
</body>

View File

@@ -1,10 +1,7 @@
{% extends "base.html" %}
{% block content %}
{# US-007 (5.5): nav-ul ad-hoc (Panou admin + logout) a fost mutat in meniul de cont (☰)
din header (base.html). Aici raman doar bara de status + tab-bar-ul de lucru zilnic. #}
<!-- Bara de status (US-002): mereu vizibila, deasupra tab-bar-ului -->
<!-- Bara de status: mereu vizibila -->
<div id="status-bar" class="status-bar card"
hx-get="/_fragments/status"
hx-trigger="load, every 15s"
@@ -12,80 +9,9 @@
<div class="empty muted" style="padding:8px 0;">se incarca starea…</div>
</div>
<!-- Tab-bar: navigare intre sectiuni -->
<div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard">
{# US-007 (5.5): tab-bar redus la suprafetele de LUCRU ZILNIC (Acasa·Mapari).
Cont/Integrare/Nomenclator s-au mutat in meniul de cont (☰) din header — rutele
`/_fragments/{cont,integrare,nomenclator}` + deep-link `?tab=` raman valide. #}
{% set tabs = [
("acasa", "Acasa", "tab-acasa"),
("mapari", "Mapari", "tab-mapari")
] %}
{% for tab_id, tab_label, tab_elem_id in tabs %}
{% set badge = (badges.get(tab_id, 0) if badges else 0) %}
<a id="{{ tab_elem_id }}"
role="tab"
href="/?tab={{ tab_id }}"
aria-selected="{{ 'true' if active_tab == tab_id else 'false' }}"
aria-controls="tab-panel"
{% if badge %}aria-label="{{ tab_label }}, {{ badge }} necesita atentie"{% endif %}
class="tab-link{% if active_tab == tab_id %} tab-activ{% endif %}"
tabindex="{{ '0' if active_tab == tab_id else '-1' }}"
hx-get="/_fragments/{{ tab_id }}"
hx-target="#tab-panel"
hx-swap="innerHTML"
hx-push-url="/?tab={{ tab_id }}">{{ tab_label }}{% if badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ badge }}</span>{% endif %}</a>
{% endfor %}
</div>
<!-- Panou activ: randat server-side la full load; HTMX inlocuieste continutul la click pe tab -->
<div id="tab-panel" role="tabpanel" aria-labelledby="tab-{{ active_tab }}" class="tab-panel">
<!-- Panou activ: randat server-side la full load (Acasa implicit, sau ?tab= prin meniu) -->
<div id="tab-panel" class="tab-panel">
{{ panel_html | safe }}
</div>
<script>
(function() {
/* Navigare cu sageti intre tab-uri (ARIA pattern) — scoped pe fiecare tablist.
Folosim querySelectorAll pentru a suporta multiple tablist-uri pe pagina
(tab-bar principal + tab-urile interne din panoul Integrare). */
document.querySelectorAll('[role="tablist"]').forEach(function(tablist) {
var tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
if (!tabs.length) return;
tablist.addEventListener('keydown', function(e) {
var idx = tabs.indexOf(document.activeElement);
if (idx === -1) return;
var next = -1;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
next = (idx + 1) % tabs.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
next = (idx - 1 + tabs.length) % tabs.length;
} else if (e.key === 'Home') {
next = 0;
} else if (e.key === 'End') {
next = tabs.length - 1;
}
if (next !== -1) {
e.preventDefault();
tabs[next].focus();
}
});
/* La click pe tab: actualizeaza aria-selected + tabindex (scoped pe tablist-ul curent) */
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
tabs.forEach(function(t) {
t.setAttribute('aria-selected', 'false');
t.setAttribute('tabindex', '-1');
t.classList.remove('tab-activ');
});
tab.setAttribute('aria-selected', 'true');
tab.setAttribute('tabindex', '0');
tab.classList.add('tab-activ');
});
});
});
})();
</script>
{% endblock %}

View File

@@ -1,26 +1,23 @@
"""Worker RAR — proces propriu (NU task asyncio in uvicorn; plan.md sect. 4).
"""Worker RAR — proces propriu (NU task asyncio in uvicorn).
Bucla: heartbeat -> recupereaza orfane -> claim atomic -> login -> postPrezentare -> update.
Ruleaza ca proces separat sub `restart: always` (docker compose).
T2 implementat:
- claim atomic anti-race (BEGIN IMMEDIATE), respecta next_attempt_at (backoff).
- reconciliere anti-duplicat pe raspuns pierdut: pe eroare tranzitorie/timeout SAU pe
rand 'sending' orfan (worker mort mid-POST), interogheaza finalizate si match pe
vin+dataPrestatie+odometruFinal; daca exista -> 'sent' (NU re-trimite).
- retry/backoff exponential pe erori tranzitorii; peste worker_max_retries -> 'error' (banner).
- retry/backoff exponential pe erori tranzitorii; peste worker_max_retries -> 'error'.
- lease/timeout pe randuri 'sending' orfane.
- re-login la token expirat (401 mid-sesiune) — JWT 30h, retry NU plafonat la 30h.
Creds per-cerere (plan sect. 5): fiecare submission poarta creds RAR CRIPTATE
(rar_creds_enc). Worker-ul face login per CONT cu acele creds, cache-uieste JWT
(30h) in memorie si STERGE creds-urile contului dupa primul login reusit. Token-ul
in memorie acopera restul trimiterilor; la restart token-ul se pierde si contul
re-loghează la urmatorul submission care aduce creds proaspete (degradare acceptata).
Creds per-cerere: fiecare submission poarta creds RAR CRIPTATE (rar_creds_enc).
Worker-ul face login per CONT cu acele creds, cache-uieste JWT (30h) in memorie si
STERGE creds-urile contului dupa primul login reusit. Token-ul in memorie acopera
restul trimiterilor; la restart token-ul se pierde si contul re-logheaza la urmatorul
submission care aduce creds proaspete (degradare acceptata).
Dev: `worker_use_test_creds` foloseste creds <test> cand submission-ul nu are enc.
Ce NU e inca: criptare PII payload at-rest (P2), b64Image mare pe disc (P2).
Pornire: python -m app.worker
"""
@@ -61,8 +58,8 @@ def _iso(dt: datetime) -> str:
def _wlog(conn, tip: str, mesaj: str, *, nivel: str = "INFO", account_id=None, cod=None, context=None) -> None:
"""Migrare print -> jurnal structurat (US-005): emite evenimentul (sursa=worker, dublu
canal DB+fisier) SI pastreaza linia in stdout (operatorul tailuieste .run/worker.log)."""
"""Emite evenimentul (sursa=worker, dublu canal DB+fisier) SI pastreaza linia in
stdout (operatorul tailuieste .run/worker.log)."""
print(f"[worker] {mesaj}", flush=True)
log_event(tip, nivel=nivel, account_id=account_id, cod=cod, mesaj=mesaj, context=context,
conn=conn, sursa="worker")
@@ -84,17 +81,17 @@ def _is_transient(exc: Exception) -> bool:
# --- Operatii pe submissions ---
# Stari blocate ne-sent care primesc retentie proprie (US-013). Mai scurta decat
# cele 90z ale `sent`: un blocat n-are valoare de audit ca o trimitere reusita.
# Stari blocate ne-sent care primesc retentie proprie. Mai scurta decat cele 90z
# ale `sent`: un blocat n-are valoare de audit ca o trimitere reusita.
_BLOCKED_STATES = ("error", "needs_data", "needs_mapping")
def mark(conn, submission_id: int, status: str, *, rar_status_code=None, rar_error=None, id_prezentare=None) -> None:
if status == "sent":
# T16: purge_after = sent + 90 zile (GDPR/L.142 retentie maxima).
# purge_after = sent + 90 zile (GDPR/L.142 retentie maxima).
purge_expr = "datetime('now', '+90 days')"
elif status in _BLOCKED_STATES:
# US-013: randurile blocate primesc si ele purge_after (altfel raman permanent).
# Randurile blocate primesc si ele purge_after (altfel raman permanent).
days = int(get_settings().blocked_retention_days)
purge_expr = f"datetime('now', '+{days} days')"
else:
@@ -114,15 +111,15 @@ def mark(conn, submission_id: int, status: str, *, rar_status_code=None, rar_err
)
# T16: purge interval in secunde (odata pe ora, nu prea agresiv)
# Purge interval in secunde (odata pe ora, nu prea agresiv)
_PURGE_INTERVAL_S = 3600
def purge_expired(conn) -> dict[str, int]:
"""Sterge randurile expirate (purge_after < now).
T16/OV-5 + US-013/US-008: submissions `sent` SI blocate (error/needs_data/needs_mapping)
expirate; import_batches expirate (import_rows via CASCADE); app_events expirate (jurnal).
Submissions `sent` SI blocate (error/needs_data/needs_mapping) expirate;
import_batches expirate (import_rows via CASCADE); app_events expirate (jurnal).
EXCLUDE explicit `queued`/`sending` (randuri active — nu se purjeaza niciodata, chiar
daca ar avea un purge_after rezidual; reactivarea il curata oricum).
Intoarce {submissions_purged, batches_purged, events_purged}.
@@ -174,7 +171,7 @@ def claim_one(conn) -> dict | None:
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
"WHERE s.status='queued' "
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
# Gate pe stare de cont (5.5): doar 'active' trimite. Derivam defensiv din `active`
# Gate pe stare de cont: doar 'active' trimite. Derivam defensiv din `active`
# cand `status` lipseste (DB veche pre-migrare), pastrand active=1 <=> 'active'.
"AND COALESCE(a.status, CASE WHEN COALESCE(a.active,1)=1 THEN 'active' ELSE 'pending' END) = 'active' "
"ORDER BY s.id LIMIT 1",
@@ -253,7 +250,7 @@ def process_one(conn, settings: Settings, rar: RarClient, token: str, claimed: d
# RAR a raspuns DEFINITIV cu o eroare de procesare (ex. ORA-12899). NU e o
# pierdere de raspuns ambigua -> NU reconcilia (recordul, daca exista la RAR,
# e PARTIAL/rupt si nu trebuie marcat fals 'sent') si NU reincerca (acelasi
# input va esua iar). Marcam 'error' cu mesajul real RAR. (Confirmat live 2026-06-23.)
# input va esua iar). Marcam 'error' cu mesajul real RAR.
detail = json.dumps(errors.eroare("RAR_EROARE_SERVER", cauza=exc.rar_message), ensure_ascii=False)
mark(conn, sid, "error", rar_status_code=500, rar_error=detail)
_wlog(conn, "submission_error", f"submission {sid} -> error (RAR 500): {exc.rar_message}",
@@ -363,7 +360,7 @@ class AccountSessions:
token = rar.login(creds["email"], creds["password"])
except RarAuthError as exc:
rar.close()
# US-005: login esuat (401) — FARA email/parola (doar codul HTTP + contul).
# Login esuat (401) — FARA email/parola (doar codul HTTP + contul).
log_event("rar_login", nivel="WARNING", account_id=account_id,
cod="RAR_CREDS_INVALIDE",
mesaj=f"login RAR esuat (cont {account_id}): {exc.status_code or 401}",
@@ -375,11 +372,11 @@ class AccountSessions:
raise
self._sessions[account_id] = (rar, token)
write_heartbeat(conn, rar_login_ok=True, detail=f"login RAR ok (cont {account_id})")
# US-005: login reusit (fara email/parola in clar — context curat).
# Login reusit (fara email/parola in clar — context curat).
log_event("rar_login", account_id=account_id, mesaj=f"login RAR ok (cont {account_id})",
context={"rezultat": "ok", "http": 200}, conn=conn, sursa="worker")
# Creds efemere pe submissions nu mai sunt necesare: JWT acopera retry-urile -> sterge.
# GATE PURJARE (T1/Voce#5): sterge DOAR submissions.rar_creds_enc, NU accounts.rar_creds_enc.
# GATE PURJARE: sterge DOAR submissions.rar_creds_enc, NU accounts.rar_creds_enc.
# Canal web: fallback exista in accounts -> purjarea e inofensiva (re-login dupa restart).
# Canal API pur: purjarea e identica cu Treapta 1 (neatinsa).
conn.execute(
@@ -418,7 +415,7 @@ def _creds_for(claimed: dict, settings: Settings) -> dict | None:
def _creds_from_account(conn, account_id: int) -> dict | None:
"""Fallback T1/D4: crede RAR durabile per-cont din accounts.rar_creds_enc.
"""Fallback: crede RAR durabile per-cont din accounts.rar_creds_enc.
Canal web nu are re-pusher. Cand submission n-are creds (sterse dupa primul login
sau upload web fara creds), worker-ul re-citeste din cont si poate re-login oricand.
@@ -436,7 +433,7 @@ def run() -> int:
signal.signal(signal.SIGINT, _stop)
settings = get_settings()
set_source("worker") # US-005: evenimentele worker-ului au sursa=worker (fisier app-worker.log)
set_source("worker") # evenimentele worker-ului au sursa=worker (fisier app-worker.log)
init_db()
conn = get_connection()
print(f"[worker] pornit (send_enabled={settings.worker_send_enabled}, env={settings.rar_env})", flush=True)
@@ -448,7 +445,7 @@ def run() -> int:
try:
write_heartbeat(conn, detail=f"poll (queue={_queue_depth(conn)})")
# T16: purjare periodica (odata pe ora) — NU mai frecvent.
# Purjare periodica (odata pe ora) — NU mai frecvent.
now_ts = time.time()
if now_ts - _last_purge_time >= _PURGE_INTERVAL_S:
stats = purge_expired(conn)
@@ -474,20 +471,20 @@ def run() -> int:
sid = claimed["id"]
account_id = claimed["account_id"]
# T1/US-012: randul poarta creds proaspete (rar_creds_enc != NULL) — fie prima
# trimitere a contului, fie o REACTIVARE dupa creds gresite. Invalidam sesiunea
# RAR cache-uita ca un JWT vechi (30h) din parola GRESITA sa nu trimita cu ea,
# Randul poarta creds proaspete (rar_creds_enc != NULL) — fie prima trimitere
# a contului, fie o REACTIVARE dupa creds gresite. Invalidam sesiunea RAR
# cache-uita ca un JWT vechi (30h) din parola GRESITA sa nu trimita cu ea,
# ignorand corectia. Re-login imediat cu creds-urile noi.
if claimed.get("creds_enc"):
sessions.invalidate(account_id)
# T1/D4: incearca creds din submission (canal API efemer), cu fallback la
# Incearca creds din submission (canal API efemer), cu fallback la
# accounts.rar_creds_enc (canal web durabil). Canal web n-are re-pusher.
creds = _creds_for(claimed, settings) or _creds_from_account(conn, account_id)
try:
token = sessions.get_token(conn, account_id, creds)
except RarAuthError as exc:
# Creds gresite (login 401): NU se face retry (plan, failure registry).
# Creds gresite (login 401): NU se face retry.
mark(conn, sid, "error", rar_status_code=401,
rar_error=json.dumps(errors.eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide"), ensure_ascii=False))
# rar_login esuat e deja logat in get_token; aici doar tranzitia submission-ului.

View File

@@ -9,13 +9,11 @@ services:
api:
build: .
command: uvicorn app.main:app --host 0.0.0.0 --port 8010
ports:
- "8010:8010"
volumes:
- autopass-data:/data
environment:
AUTOPASS_DB_PATH: /data/autopass.db
AUTOPASS_RAR_ENV: test
AUTOPASS_RAR_ENV: prod
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
AUTOPASS_REQUIRE_API_KEY: ${AUTOPASS_REQUIRE_API_KEY:-false}
restart: always
@@ -35,7 +33,7 @@ services:
AUTOPASS_RAR_ENV: test
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
# Send dezactivat by default; activeaza pentru proba end-to-end.
AUTOPASS_WORKER_SEND_ENABLED: "false"
AUTOPASS_WORKER_SEND_ENABLED: "true"
restart: always
depends_on:
- api

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,430 @@
<!-- /autoplan restore point: ~/.gstack/projects/romfast-rar-autopass/main-autoplan-restore-20260625-120049.md -->
# PRD 5.10 — UX trimiteri (pill filtre, paginare, detaliu) + Mapari in meniu
**Stare**: inchis (2026-06-25 — 14 stories + fix US-006b TDD prin echipa; VERIFY PASS; `/code-review high` 1 finding material reparat; regresie 896 passed / 1 skipped / 0 failed; fonturi IBM Plex reale)
> Proces complet: `docs/ROADMAP.md` §5. Contractul RAR (sursa de adevar): `docs/api-rar-contract.md`.
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
## 1. Obiectiv
Curatare UX a dashboard-ului pe doua zone: (a) **tabelul de trimiteri** — pill-uri de filtrare
pe categorii de problema in locul listei expandate de ID-uri, filtrare pe interval de data care
chiar functioneaza pe timestamp-uri, paginare numerotata, VIN sub numarul de inmatriculare,
editarea operatiei RAR si afisarea operatiei de service in detaliu, plus erori mai putin verbose
in formularul de editare; (b) **Mapari** — mutata in meniul hamburger (fara tab-uri pe pagina
principala), o singura pagina cu sectiunile consolidate si butoane de salvare/stergere vizibile.
Pur UI/UX + un fix de filtrare backend. Backend-ul de trimitere (worker, masina de stari,
idempotenta, mapping-rezolvare) ramane **NEATINS**.
## 2. Non-Goals (anti scope-creep)
- **Nu** atingem worker-ul, masina de stari, idempotenta sau logica de mapare operatie→cod.
- **Nu** permitem editarea operatiei RAR pe stari trimise/in curs (`sent`/`sending`/`queued`) —
la RAR `FINALIZATA` e terminal; editarea apare doar pe `needs_data`/`needs_mapping`/`error`.
- **Nu** schimbam paginarea client-side existenta din tabelele Mapari (datatable `data-dt`).
- **Nu** schimbam mecanismul de persistenta/anti-FOUC al temei (PRD 5.3) — il extindem cu teme noi, nu il rescriem.
- **Nu** adaugam filtre noi (doar pill-uri pe categoriile de problema deja existente + fix data).
- **Nu** modificam contractul API `POST /v1/prezentari` / `GET /v1/prezentari` (operatia de
service afisata vine din payload-ul deja stocat, nu cere camp nou).
- **Nu** reproiectam meniul hamburger in sine — doar adaugam intrarea „Mapari" si scoatem tab-bar-ul.
- **Acceptat explicit (review C4)**: doua idiomuri de paginare coexista — server-side numerotat pe Trimiteri (US-004) vs client-side `data-dt` (Inapoi/Inainte) pe Mapari. Diferenta e intentionata, nu „de reparat" ulterior.
- **Acceptat explicit (review C2)**: butonul ciclic de tema (US-014) are cost de descoperire (4 teme fara optiuni vizibile). Compensat prin `aria-label` curenta+urmatoarea + tooltip; nu trecem la meniu/popover in aceasta livrabila.
## 3. Stories atomice
> Fiecare story: cea mai mica unitate care lasa sistemul functional. Backend + UI pentru acelasi
> comportament = 2 stories.
### US-001: Fix filtrare pe interval de data (backend)
**Ca** operator **vreau** ca filtrul „Data de la / pana la" sa returneze toate trimiterile din
acea perioada **pentru ca** acum, fiindca `data_prestatie` poate avea ora/minut/secunda,
comparatia de string exclude randurile si tabelul apare gol.
- **Depinde de**: —
- **Fisiere**: `app/web/routes.py` (`fragment_submissions`, `_is_iso_date`), `tests/test_web_filtre_submissions.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_filtre_submissions.py``test_filtru_data_include_timestamp_cu_ora`, `test_filtru_data_interval_inclusiv_capete`, `test_filtru_data_ignora_valori_ne_data`
- **Acceptance criteria**:
- [ ] **(must-fix, review C1)** Cauza reala = garda `_is_iso_date` cere `len(s)==10` (`routes.py:640-649`), deci orice `data_prestatie` cu ora e **exclusa inainte** de comparatie. Fix: introdu `_iso_date_prefix(value) -> str | None` (intoarce `value[:10]` daca parseaza ca `YYYY-MM-DD`, altfel `None`) si foloseste-l **atat** pentru garda **cat si** pentru comparatie. Nu modifica doar liniile de comparatie — altfel bug-ul ramane.
- [ ] Filtrarea compara doar **portiunea de data** (primele 10 caractere, `YYYY-MM-DD`) a lui `data_prestatie`, chiar daca valoarea contine ora/minut/secunda (ex. `2026-06-20 14:35:07` sau `2026-06-20T14:35:07`).
- [ ] Intervalul e **inclusiv la ambele capete**: `data_de <= data(rand) <= data_pana`.
- [ ] O singura limita (doar `data_de` sau doar `data_pana`) functioneaza corect.
- [ ] Valorile care nu incep cu o data ISO valida raman excluse din filtru (comportament actual pastrat).
- [ ] `python3 -m pytest tests/test_web_filtre_submissions.py -q` trece.
- **Verificare E2E**: browser pe `/` — import/seed cu o trimitere cu `data_prestatie` ce contine ora; filtru pe acea zi → randul apare.
### US-002: Expune operatia de service in view-ul de rand si detaliu (backend)
**Ca** operator **vreau** sa vad operatia de service originala (codul intern / denumirea venita
prin API sau import CSV) **pentru ca** vreau sa stiu ce a cerut service-ul, nu doar codul RAR mapat.
- **Depinde de**: —
- **Fisiere**: `app/payload_view.py` (sau `app/web/routes.py` `_detaliu_ctx`/`prezentare_din_payload`), `tests/test_payload_view.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_payload_view.py``test_operatie_service_din_cod_op_service`, `test_operatie_service_din_denumire`, `test_fara_operatie_service_cand_lipseste`
- **Acceptance criteria**:
- [ ] **(must-fix, review M1)** `prezentare_din_payload` colapseaza acum service-op si RAR-op in chei suprapuse (`operatie = denumire or cod`, `cod_rar = cod_prestatie`; `payload_view.py:111-113`). Adauga **chei noi distincte** (ex. `op_service_cod` + `op_service_denumire`) din `cod_op_service`/`denumire`, separate de operatia RAR mapata.
- [ ] **Conventie goala (must-fix, review M1)**: payload_view emite `EMPTY="—"` pentru valori lipsa (`payload_view.py:119-127`), dar US-007 cere „randul nu apare deloc (fara — gol)". Alege O conventie: fie campul nou intoarce `""`/`None` (nu `—`), fie template-ul testeaza fata de `'—'` (ca randul VIN, `_submissions.html:63`). Documenteaz-o aici si in US-007.
- [ ] Cand payload-ul nu contine operatie de service (a venit direct cu `cod_prestatie`), campul e gol conform conventiei de mai sus, fara a arunca.
- [ ] Helper-ul ramane pur (fara DB), defensiv la payload lipsa/corupt.
- [ ] `python3 -m pytest tests/test_payload_view.py -q` trece.
- **Verificare E2E**: `POST /v1/prezentari` cu `cod_op_service`+`denumire` → randul are operatia de service in context.
### US-003: Pill-uri de filtrare pe categorii de problema (UI)
**Ca** operator **vreau** pill-uri „Date incomplete / Lipsa cod / Eroare" cu numar in sectiunea de
filtrare, in loc de lista expandata `#40 TMB...3456 / B28ERR ...si inca 3` **pentru ca** lista de
ID-uri e zgomotoasa; vreau sa apas un pill ca sa filtrez direct pe acea categorie.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_coada.html`, `app/web/templates/_status.html`, `app/web/routes.py` (counts per categorie pe fragmentul de filtre), `tests/test_web_pill_filtre.py` (~4 fisiere)
- **Test intai (RED)**: `tests/test_web_pill_filtre.py``test_pill_per_categorie_cu_numar`, `test_pill_click_seteaza_status`, `test_fara_lista_id_uri`
- **Acceptance criteria**:
- [ ] In sectiunea de filtrare apar pill-uri: **Date incomplete** (`needs_data`), **Lipsa cod** (`needs_mapping`), **Eroare** (`error`), fiecare cu numarul total scoped pe cont; pill-ul lipseste/e ascuns cand numarul e 0.
- [ ] **(must-fix, review M3/design-H1)** Lista de ID-uri si blocul `blocate_actionabil` sunt in **`_status.html:68-77`** (nu `_coada.html`), generate de `_blocate_actionabil` (`routes.py:562-594`). La eliminarea ID-urilor, scoate si codul mort care calcula `prezentare_din_payload`/`vin_partial` per rand. Pill-urile reutilizeaza contoarele deja calculate (`_status_counts`/`n`) — fara filtru backend nou.
- [ ] **(must-fix, review design-M1)** Decide explicit soarta intregului bloc „Necesita atentia ta" + a contorului „Blocate": pill-urile **inlocuiesc** link-urile de categorie (nu triplu-encoda aceeasi informatie cu pill + link + dropdown).
- [ ] **(must-fix, review M3/A3)** Pill-urile sunt elemente focalizabile reale (`<button>`/`<a>`, nu `<span onclick>`), cu `focus-visible`, activare Enter/Space, si stare activa via `aria-pressed` (nu doar culoare).
- [ ] Click pe un pill filtreaza tabelul pe acea stare (seteaza `status=` pe `fragment_submissions`, deja suportat `routes.py:699`); pill activ evidentiat; click pe activ revine la „toate".
- [ ] **(must-fix, review M3)** Sincronizare cu dropdown-ul `status` din `_coada.html:36-44`: ori dropdown-ul reflecta pill-ul activ, ori e eliminat — fara desync (click pill vs valoare dropdown stale).
- [ ] Exista si un control „Toate" (toggle) care reseteaza filtrul de categorie. Stare „toate zero" (steady-state sanatos): definita explicit (rand ascuns vs afisaj muted „nicio problema"), nu gap neexplicat.
- [ ] **(must-fix, review S1/A5)** Matrice de stil pill rezolvata fara contradictie rosu/galben vs accent: inactiv = contur/text pe culoarea categoriei (`--err`/`--warn`); activ = umplere pe **culoarea categoriei** (NU accent albastru — altfel pill rosu „Date incomplete" devine albastru cand e activ). Contrast text pe pill-ul activ verificat AA in toate cele 3 teme.
- [ ] `python3 -m pytest tests/test_web_pill_filtre.py -q` trece.
- **Verificare E2E**: browser pe `/` — pill-uri cu numere; click pe „Date incomplete" → tabel filtrat; click din nou → toate.
### US-004: Paginare numerotata pe tabelul de trimiteri (backend + UI)
**Ca** operator **vreau** paginare numerotata pe tabelul de trimiteri **pentru ca** acum se
incarca max 200 randuri fara navigare si nu pot ajunge la cele mai vechi.
- **Depinde de**: US-001 (filtrul de data corect intra in acelasi handler)
- **Fisiere**: `app/web/routes.py` (`fragment_submissions`: param `page`, total + slice ramificat — vezi C1), `app/web/templates/_submissions.html`, `app/web/templates/_coada.html` (poll `hx-include` + `page`), `tests/test_web_paginare_submissions.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_paginare_submissions.py``test_pagina_implicita_25`, `test_pagina_2_offset`, `test_total_si_numar_pagini`, `test_paginarea_pastreaza_filtrele`, `test_pagina_peste_total_revine_la_ultima`, `test_poll_pastreaza_pagina`
- **Acceptance criteria**:
- [ ] **(must-fix, review H1)** Numararea totalului ramifica dupa tipul de filtru: **fara** filtru Python (status-only / niciun filtru) → SQL `COUNT(*)` + `LIMIT 25 OFFSET (page-1)*25`; **cu** filtru vehicul/data activ → fetch-all (fara LIMIT, ca azi) → filtreaza in Python → `total=len(filtrat)` → slice `[offset:offset+25]`. SQL `COUNT(*)/LIMIT/OFFSET` e **gresit** cand filtrul Python e activ. Plafonul de 200 randuri din bucla se inlocuieste cu „fetch-all-then-slice" pe calea filtrata (altfel paginile >8 dispar silentios).
- [ ] Marime pagina fixa **25** randuri; raspunsul include numarul total si pagina curenta.
- [ ] **(must-fix, review H2)** `page` in afara intervalului se clampeaza la `[1, ceil(total/25)]` (nu pagina goala); schimbarea unui filtru reseteaza `page` la 1.
- [ ] Controale: ` Inapoi`, numere de pagina, `Inainte `; pagina curenta evidentiata cu `aria-current="page"`; capetele dezactivate (`disabled`) la prima/ultima pagina; pager-ul e **ascuns** cand `pages<=1` sau `total==0` (empty state inlocuieste tabelul). „afiseaza X-Y din N" intr-o regiune `aria-live="polite"`.
- [ ] Schimbarea paginii **pastreaza** filtrele active (status/pill, vehicul, data_de, data_pana) — link-urile de paginare poarta toti parametrii de filtru curenti.
- [ ] **(must-fix, review L2)** Poll-ul de 15s (`_coada.html` `hx-include="#filtre-trimiteri"`) NU trebuie sa reseteze pagina: include `page` curent in include-ul de poll (hidden input actualizat de paginare) SAU documenteaza explicit reset-pe-poll. Test: `test_poll_pastreaza_pagina`.
- [ ] `python3 -m pytest tests/test_web_paginare_submissions.py -q` trece.
- **Verificare E2E**: browser pe `/` cu >25 trimiteri — navigare intre pagini, filtru aplicat ramane la schimbarea paginii, poll-ul nu te scoate de pe pagina curenta.
### US-005: VIN sub numarul de inmatriculare in tabel (UI)
**Ca** operator **vreau** ca VIN-ul sa apara pe rand propriu sub numarul de inmatriculare **pentru
ca** pe aceeasi linie e ingramadit si greu de citit.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_submissions.html`, `tests/test_web_submissions_layout.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_submissions_layout.py``test_vin_pe_rand_separat_sub_nr`
- **Acceptance criteria**:
- [ ] **(nota, review L1)** VIN-ul e deja randat in `_submissions.html:61-65`, dar **inline** (`<span>` dupa nr, aceeasi celula). Schimbarea e mica: element block sub nr (nu structura noua). Testul asserteaza un element **block-level**, nu doar prezenta.
- [ ] In coloana Vehicul, numarul de inmatriculare e pe primul rand; VIN-ul (sau partiala VIN) apare **dedesubt**, in stil muted, nu pe aceeasi linie.
- [ ] Cand VIN-ul lipseste, nu apare rand gol (garda `!= '—'` exista deja, `_submissions.html:63`).
- [ ] Layout-ul ramane fara scroll orizontal pe tabel (scopat `.tabel-trimiteri`, consistent cu PRD 5.8).
- [ ] `python3 -m pytest tests/test_web_submissions_layout.py -q` trece.
- **Verificare E2E**: browser pe `/` — VIN sub numar in fiecare rand.
### US-006: Editare operatie RAR in formularul de detaliu (UI + backend corectie)
**Ca** operator **vreau** sa pot schimba operatia RAR (`cod_prestatie`) din formularul de detaliu pe
trimiterile blocate **pentru ca** uneori codul mapat e gresit si vreau sa-l corectez inainte de re-trimitere.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_trimitere_detaliu.html`, `app/web/routes.py` (`post_corectie_trimitere` ~979-1124), `tests/test_web_editare_op_rar.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_editare_op_rar.py``test_editabil_arata_select_cod_rar`, `test_salvare_schimba_cod_si_repune_in_coada`, `test_idempotency_key_se_schimba`, `test_cod_invalid_respins`, `test_sent_nu_arata_select`
- **Acceptance criteria**:
- [ ] Pe `needs_data`/`needs_mapping`/`error`, formularul afiseaza un **select cu codurile din nomenclator** pentru operatia RAR, pre-selectat pe codul curent.
- [ ] **(must-fix, review H3)** Riscul „refoloseste exact calea existenta" e GRESIT formulat: `post_corectie_trimitere` aplica DOAR campurile vehiculului (`vin/nr/data/odometru`), nu citeste niciun cod de prestatie (`routes.py:1009-1012`). Story-ul cere logica **noua** in handler: (a) camp nou `cod_prestatie` in form; (b) validare fata de nomenclator (oglindeste `routes.py:953-961`); (c) injectare in `content["prestatii"][i]["cod_prestatie"]` inainte de `resolve_prestatii`. DUPA injectare, restul caii existente (canonicalize → `build_key` → check coliziune idempotency → re-queue, `routes.py:1028-1104`) recalculeaza corect payload+cheie (`build_key` hashuieste `cod_prestatie`, `idempotency.py:34`).
- [ ] Codul nou e validat fata de nomenclator; un cod necunoscut e **respins** (nu se injecteaza, nu se trimite raw — vezi invariantul `cod_prestatie` validat la ingestie din CLAUDE.md).
- [ ] Test `test_idempotency_key_se_schimba`: cheia de idempotency **difera** dupa schimbarea codului (nu doar `status=queued`).
- [ ] Daca payload-ul are mai multe prestatii, story-ul tinteste prima/operatia editabila explicit (nu ambiguu).
- [ ] Pe `sent`/`sending`/`queued` operatia RAR ramane **read-only** (fara select).
- [ ] Scoped pe sesiune + CSRF, 404 cross-account.
- [ ] `python3 -m pytest tests/test_web_editare_op_rar.py -q` trece.
- **Verificare E2E**: browser pe `/` — pe o trimitere `needs_data`, schimba codul RAR din select, salveaza → cod nou aplicat + cheie idempotency noua; cod invalid → respins; pe o trimitere `sent`, codul e read-only.
### US-007: Afisare operatie de service in detaliu (UI)
**Ca** operator **vreau** sa vad operatia de service originala in formularul de detaliu **pentru
ca** vreau sa stiu ce a cerut service-ul prin API/CSV, alaturi de codul RAR.
- **Depinde de**: US-002
- **Fisiere**: `app/web/templates/_trimitere_detaliu.html`, `tests/test_web_detaliu_op_service.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_detaliu_op_service.py``test_detaliu_arata_operatie_service`, `test_detaliu_omite_cand_lipseste`
- **Acceptance criteria**:
- [ ] In detaliu apare „Operatie service" (cod intern + denumire) cand a existat in payload, distinct de „Operatie RAR".
- [ ] Cand operatia de service lipseste (a venit direct cu `cod_prestatie`), randul nu apare deloc (fara „—" gol).
- [ ] Apare atat in contextul editabil cat si in cel read-only.
- [ ] `python3 -m pytest tests/test_web_detaliu_op_service.py -q` trece.
- **Verificare E2E**: browser pe `/` — trimitere venita cu `cod_op_service` arata operatia de service in detaliu; una venita cu `cod_prestatie` direct nu o arata.
### US-008: Simplificare eroare in formularul de editare (UI)
**Ca** operator **vreau** ca in formularul de editare sa apara doar textul simplu al erorii (subliniat),
nu blocul verbose pe 3 niveluri **pentru ca** „De ce / Cum repari" + prefixul tehnic dubleaza un
mesaj deja descriptiv (ex. „odometruFinal trebuie sa fie un numar intreg (ca string).").
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_trimitere_detaliu.html` (zona editabila), `tests/test_web_detaliu_eroare_simpla.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_detaliu_eroare_simpla.py``test_form_editare_fara_card_3niveluri`, `test_eroare_pe_camp_doar_text_simplu`
- **Acceptance criteria**:
- [ ] **(must-fix, review M4)** Blocul `erori_3n`/`card_erori` e acum randat **inainte** de form, in afara ramurii `{% if editabil %}` (`_trimitere_detaliu.html:22-27`) — deci apare si pe read-only. Ascunde-l DOAR in editare: muta-l/infasoara-l in `{% if not editabil %}` ca sa-l pastrezi pe contextele read-only (catalogul PRD 5.4).
- [ ] Eroarea per camp ramane sub input ca **text simplu subliniat** (mesajul descriptiv). Nota: macro-ul `camp` printeaza deja doar `err_map.get(nume)` (mesajul, fara cod de camp) — verifica doar ca `message` nu inglobeaza numele campului.
- [ ] **(must-fix, review M6/design)** Erorile **fara camp** (`field is None`) nu trebuie sa dispara silentios cand scoatem cardul 3n: defineste unde apar (ex. un rezumat simplu top-of-form) in formularul de editare.
- [ ] Restul contextelor (lista compacta / detaliu read-only) raman neschimbate — simplificarea e scopata doar pe formularul de editare.
- [ ] `python3 -m pytest tests/test_web_detaliu_eroare_simpla.py -q` trece.
- **Verificare E2E**: browser pe `/` — corectie cu odometru invalid → sub camp apare doar textul erorii subliniat, fara cardul pe 3 niveluri.
### US-009: Mapari in meniul hamburger + scoatere tab-uri (UI)
**Ca** operator **vreau** ca Mapari sa fie o intrare in meniul hamburger, nu tab pe pagina
principala **pentru ca** vreau pagina principala curata (doar Acasa), iar tab-bar-ul Acasa/Mapari
incurca.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/base.html` (meniu `#cont-menu`), `app/web/templates/dashboard.html` (tab-bar), `app/web/routes.py` (rutare pagina Mapari, ex. `/?tab=mapari` sau `/mapari`), `tests/test_web_mapari_meniu.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_mapari_meniu.py``test_meniu_contine_mapari`, `test_pagina_principala_fara_tabbar_mapari`, `test_ruta_mapari_randeaza_sectiunea`
- **Acceptance criteria**:
- [ ] Meniul hamburger contine o intrare **Mapari** (cu badge pentru `needs_mapping` daca exista contoare). Badge-ul muta din tab-bar pe item-ul de meniu — sursa contorului ramane `badges.mapari` (`routes.py:423-424`); verifica sa nu ramana badge dangling.
- [ ] Tab-bar-ul Acasa/Mapari de pe pagina principala e **eliminat**; Acasa devine continutul principal direct.
- [ ] **(must-fix, review design-C1)** Scoate si schela ARIA `role="tablist"` orfana + JS-ul de navigare cu sageti din `dashboard.html:16-88` (un `role="tablist"` cu un singur tab e o violare ARIA). Curata plumbing-ul `active_tab` ramas fara sens daca nu mai e folosit.
- [ ] Exista o ruta dedicata care randeaza pagina Mapari (server-side, deep-link). `?tab=mapari` ramane valid (`_TABS_VALIDE` include `mapari`, `routes.py:155`; randat de `_render_panel_for_tab`) — nu da 404. Verifica sa nu ramana `hx-get` catre un element de tab eliminat (banner/badge).
- [ ] `python3 -m pytest tests/test_web_mapari_meniu.py -q` trece.
- **Verificare E2E**: browser pe `/` — fara tab-bar; din ☰ → Mapari → pagina Mapari.
### US-010: Restructurare pagina Mapari intr-o singura pagina consolidata (UI)
**Ca** operator **vreau** o singura pagina Mapari cu „De rezolvat" prima, apoi salvate / reguli text /
formate coloane, fara sectiunea de ajutor si fara textul gol **pentru ca** sectiunile separate si
textele de ajutor ma incurca.
- **Depinde de**: US-009
- **Fisiere**: `app/web/templates/_mapari.html`, `tests/test_web_mapari_layout.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_mapari_layout.py``test_de_rezolvat_prima`, `test_fara_ajutor_si_empty_text`, `test_ordine_sectiuni`
- **Acceptance criteria**:
- [ ] Ordinea sectiunilor: (1) **De rezolvat** prima, (2) Mapari operatii salvate, (3) Reguli automate (text), (4) Formate de coloane salvate.
- [ ] Sectiunea de ajutor (`<details class="ajutor-mapari">`) e **eliminata**.
- [ ] Textul empty-state „Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR. Importa un fisier nou..." e **eliminat** (sectiunea „De rezolvat" goala nu mai afiseaza acel paragraf).
- [ ] Fiecare rand din „De rezolvat" pastreaza select-ul + butonul de salvare vizibil (vezi US-011).
- [ ] `python3 -m pytest tests/test_web_mapari_layout.py -q` trece.
- **Verificare E2E**: browser pe pagina Mapari — ordine corecta, fara ajutor, fara empty-text.
### US-012: Branding header „by ROMFAST" + titlu centrat (UI)
**Ca** utilizator **vreau** ca header-ul sa aiba branding-ul „by ROMFAST" si titlul pe mijloc
**pentru ca** vreau identitate vizuala clara, parte din familia ROMFAST/ROA, nu un header anonim.
> Sistem de design complet: `DESIGN.md` (sectiunile „Header & branding" + „Culori de brand").
- **Depinde de**: —
- **Fisiere**: `app/web/templates/base.html` (header + CSS), `tests/test_web_header_branding.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_header_branding.py``test_header_contine_by_romfast`, `test_titlu_centrat`
- **Acceptance criteria**:
- [ ] Titlul „Gateway RAR AUTOPASS" e **centrat** in header (grila 3 coloane: controale — tema, versiune, ☰ — la **dreapta**, fara a strica centrarea optica a titlului).
- [ ] **(must-fix, review S2)** Plaseaza explicit badge-ul `env` (test/prod, azi `base.html:320`) in grila — celula stanga (echilibru) sau langa titlu — si defineste ordinea de colaps pe mobil (3-col cu titlu centrat + controale care wrap e fragil).
- [ ] Sub titlu, mic: wordmark **„by ROMFAST"** redat ca text stilizat — `by` in `--muted`, `ROM` in `#D1342F` (rosu logo), `FAST` in `#2E74D6` (albastru logo). **NU** se foloseste PNG-ul 3D al logo-ului.
- [ ] Responsiv: pe mobil wordmark-ul ramane sub titlu, controalele nu se suprapun (degrada elegant).
- [ ] Light + dark OK (wordmark pe culori proprii, lizibil pe ambele fundaluri).
- [ ] `python3 -m pytest tests/test_web_header_branding.py -q` trece.
- **Verificare E2E**: browser pe `/` desktop + mobil — titlu centrat, „by ROMFAST" cu ROM rosu / FAST albastru, controale la dreapta.
### US-013: Tema de culori ROMFAST + tipografie (UI)
**Ca** utilizator **vreau** o paleta cu accent albastru ROMFAST (ca romfast.ro) si o tipografie
coerenta **pentru ca** acum totul e gri si fara identitate, iar produsul e parte din familia ROA.
> Valori complete: `DESIGN.md` (sectiunile „Decizie cromatica" + „Tipografie").
- **Depinde de**: design-consultation (DONE — `DESIGN.md` scris)
- **Fisiere**: `app/web/templates/base.html` (variabile `:root` + `[data-theme="light"]` + `font-family`), `app/web/static/fonts/` (woff2 IBM Plex), `tests/test_web_tema_culori.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_tema_culori.py``test_paleta_accent_azur_definita`, `test_font_ibm_plex_aplicat`, `test_contrast_aa_pe_text_principal`
- **Acceptance criteria**:
- [ ] Accentul devine **azur ROMFAST**: `--accent:#2E74D6` (dark) / `#1F66C9` (light), aplicat prin variabile CSS (butoane primare, pill activ, linkuri, focus) — fara culori noi hardcodate imprastiate.
- [ ] Neutrele actualizate conform `DESIGN.md` (dark `--bg:#0f1218`/`--card:#181c24`; light `--bg:#f5f7fa`/`--card:#ffffff`); stari `--ok/--warn/--err` pastrate AA per tema.
- [ ] **Tipografie**: `font-family` UI = IBM Plex Sans (fallback `system-ui`); monospace (coduri RAR/VIN/nr.) = IBM Plex Mono. Self-host woff2 cu `font-display:swap` (subset latin-ext pentru diacritice). Fallback de sistem nu strica layout-ul.
- [ ] **(must-fix, review M3/design)** `font-display:swap` produce un FOUT system-ui→IBM Plex; pe coloanele tabulare (VIN/coduri, `tabular-nums` `base.html:54`) o nepotrivire de metrici da reflow. Defineste fallback cu metrici ajustate (`size-adjust`/`ascent-override`) SAU accepta explicit FOUT-ul si confirma ca tabularele nu fac reflow vizibil.
- [ ] Contrastul textului principal ramane **AA** in ambele teme; accentul-ca-text pe alb foloseste varianta inchisa (`#1F66C9`).
- [ ] Comutatorul light/dark existent (PRD 5.3) + anti-FOUC functioneaza in continuare.
- [ ] `python3 -m pytest tests/test_web_tema_culori.py -q` trece.
- **Verificare E2E**: browser pe `/` — accent azur + IBM Plex in light si dark, pill-uri rotunjite ca pe romfast.ro, contrast verificat.
### US-014: Selector de tema ciclic (Light/Dark/Petrol/Auto) (UI)
**Ca** utilizator **vreau** ca butonul de tema sa cicleze prin mai multe teme (inclusiv una petrol)
**pentru ca** vreau sa-mi aleg aspectul, nu doar light/dark, ca pe demoanaf.ro.
> Spec complet: `DESIGN.md` (sectiunile „Selector de tema" + paleta „Petrol").
- **Depinde de**: US-013 (paleta azur + variabilele de baza)
- **Fisiere**: `app/web/templates/base.html` (script anti-FOUC + `[data-theme="petrol"]` + JS comutator + buton), `tests/test_web_selector_tema.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_selector_tema.py``test_petrol_theme_definit`, `test_buton_cicleaza_temele`
- **Acceptance criteria**:
- [ ] Butonul de tema **cicleaza** la click: Light → Dark → Petrol → Auto → Light; iconita + `aria-label`/`title` reflecta tema curenta.
- [ ] **(must-fix, review A2/design)** Buton accesibil: `aria-label` anunta curenta + urmatoarea („Tema: Petrol, apasa pentru Auto"); schimbarea anuntata via regiune `aria-live="polite"`. Tooltip enumera ciclul. Cost de descoperire (4 teme intr-un buton fara optiuni vizibile) acceptat explicit in Non-Goals.
- [ ] Tema noua **Petrol** definita ca `[data-theme="petrol"]` cu valorile din `DESIGN.md` (accent `--accent:#0E7C7B`, neutre petrol-inchise).
- [ ] **Auto** urmeaza `prefers-color-scheme` (rezolva la Light/Dark azur); nu seteaza `data-theme` fix.
- [ ] **(must-fix, review M4/design)** Scriptul anti-FOUC din `<head>` (`base.html:19-29`) cunoaste azi doar `light`/`dark`. Extinde-l atomic sa enumere toate cele 4 stari; o valoare `localStorage.theme` **legacy/necunoscuta** are fallback definit (nu blink, nu stare invalida). Rezolva „Auto" la light/dark inainte de primul paint.
- [ ] **(must-fix, review S3/design)** Wordmark-ul „FAST" `#2E74D6` (albastru) coexista pe ecranul Petrol cu accentul teal `#0E7C7B` — verifica armonia/contrastul wordmark-ului (ROM rosu + FAST albastru) pe **toate cele 3 teme** concrete, nu doar light/dark.
- [ ] Toate suprafetele raman lizibile (AA pe text principal) in fiecare din cele 3 teme concrete.
- [ ] `python3 -m pytest tests/test_web_selector_tema.py -q` trece.
- **Verificare E2E**: browser pe `/` — click pe buton cicleaza Light→Dark→Petrol→Auto; refresh pastreaza tema; Petrol are accent teal, fara blink la load.
### US-011: Butoane icon salvare/stergere vizibile + evidentiere modificari nesalvate (UI)
**Ca** operator **vreau** butoane mici cu icon de salvare/stergere mereu vizibile pe randurile de
mapari, evidentiate cand am modificari nesalvate **pentru ca** acum trebuie sa intru intr-un meniu
contextual (kebab) si nu imi dau seama ca trebuie sa apas „Salveaza".
- **Depinde de**: US-010
- **Fisiere**: `app/web/templates/_mapari.html`, `app/web/templates/base.html` (CSS/JS mic pentru stare „dirty"), `tests/test_web_mapari_actiuni.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_mapari_actiuni.py``test_butoane_icon_vizibile_pe_rand`, `test_fara_kebab_menu`
- **Acceptance criteria**:
- [ ] Pe „Mapari operatii salvate" si „Reguli automate (text)", actiunile Salveaza/Sterge sunt **butoane mici cu icon mereu vizibile** pe rand, nu ascunse in meniu kebab.
- [ ] **(must-fix, review A1 — decizie de taste, vezi poarta)** Icon-urile sunt **SVG/text stilizate ca icon** (reuse `.icon-btn`), NU emoji brute 💾/🗑: emoji-ul nu se recoloreaza pe teme/dirty-state si randeaza inconsistent intre OS-uri. Numele accesibil din `aria-label`; glifa decorativa `aria-hidden`.
- [ ] Meniul kebab (`position:fixed`) e **eliminat**.
- [ ] **(must-fix, review S4)** Cand utilizatorul schimba select-ul unui rand, butonul de salvare devine **evidentiat** concret (ex. fundal `--accent` + nu emoji) ca semnal de „modificari nesalvate"; in starea normala e discret. Starea „dirty" e efemera per-render (un swap `outerHTML` o reseteaza — fara persistenta asteptata).
- [ ] Confirmarea la stergere (`hx-confirm`) se pastreaza.
- [ ] **(must-fix, review A6)** Accesibil: butoanele au `aria-label` descriptiv; pe mobil regula 44px e scopata ca icon-urile sa ramana **icon-size** (nu full-width ca butoanele normale `min-height:44px;width:100%`), cu zona de atingere adecvata.
- [ ] `python3 -m pytest tests/test_web_mapari_actiuni.py -q` trece.
- **Verificare E2E**: browser pe pagina Mapari — schimba un cod RAR la o mapare salvata → butonul de salvare se evidentiaza; salveaza/sterge din icon-uri vizibile.
## 4. Riscuri
- **Filtrarea pe data (US-001)**: `data_prestatie` poate veni in formate diferite (ISO cu/ fara ora,
`T` vs spatiu). Mitigare: normalizeaza la primele 10 caractere doar cand sunt o data ISO valida;
pastreaza excluderea valorilor ne-ISO (comportament existent) + test parametrizat pe formate.
- **Paginare + filtru in Python (US-004)**: filtrele vehicul/data se aplica post-SQL; numararea
totalului trebuie sa respecte filtrul, nu doar `COUNT(*)` SQL. Mitigare: numara dupa filtrare la
scara actuala (plafon perf deja notat in cod) si testeaza `total` cu filtru activ.
- **Editare operatie RAR (US-006)**: schimbarea codului trebuie sa recalculeze payload + idempotency
ca la corectia `needs_data` existenta, altfel risca chei divergente. Mitigare: refoloseste exact
calea de corectie/mapare existenta, nu una noua.
- **Mutare Mapari din tab (US-009)**: deep-link-uri vechi `?tab=mapari` pot exista. Mitigare:
alias/redirect, test ca nu da 404.
- **Regresie pe contexte de eroare (US-008)**: simplificarea trebuie scopata DOAR pe formularul de
editare; lista compacta + detaliu read-only + API raman pe catalogul 3-niveluri (PRD 5.4).
## 5. Intrebari deschise
> Rezolvate cu utilizatorul inainte de executie (poarta de aprobare PRD). Toate clarificate prin
> AskUserQuestion in sesiunea de planificare:
- [x] Pill filtre: **un pill per categorie** (Date incomplete / Lipsa cod / Eroare) cu numar, nu un singur pill combinat.
- [x] Paginare: **pagini numerotate**, 25 randuri/pagina, total vizibil, pastreaza filtrele.
- [x] Editare operatie RAR: **doar pe stari editabile** (`needs_data`/`needs_mapping`/`error`); read-only pe `sent`/`sending`/`queued`.
- [x] Layout Mapari: **De rezolvat prima**, apoi salvate / reguli text / formate coloane; fara ajutor, fara empty-text.
- [x] Butoane icon: **evidentiere la modificare** (dirty state) pe langa vizibilitatea permanenta.
- [x] Branding + paleta (US-012/US-013): rezolvate prin `design-consultation``DESIGN.md`.
Wordmark „by ROMFAST" sub titlu (ROM rosu `#D1342F` + FAST albastru `#2E74D6`); accent functional
**azur ROMFAST** (`#2E74D6`/`#1F66C9`), consistent cu romfast.ro; font IBM Plex Sans + Mono.
- [x] Selector de tema (US-014): **buton ciclic** ca demoanaf.ro, set Light/Dark/Petrol/Auto;
petrolul (directia initiala) revine ca tema selectabila.
## 6. Valuri de executie (graful de dependente)
```
Val 1 (paralel, fisiere disjuncte):
[US-001] fix filtrare data (routes.py)
[US-002] operatie service in view (payload_view.py)
[US-003] pill-uri filtre (_coada.html / _status.html)
[US-005] VIN sub nr (_submissions.html)
[US-008] eroare simpla in editare (_trimitere_detaliu.html)
[US-009] Mapari in meniu (base.html / dashboard.html)
Val 2 (deblocate de Val 1):
[US-004] paginare ← US-001 (acelasi handler)
[US-006] editare op RAR ← (independent, dar atinge _trimitere_detaliu.html → dupa US-008)
[US-007] op service detaliu ← US-002
[US-010] restructurare Mapari ← US-009
Val 3:
[US-011] butoane icon + dirty state ← US-010
Branding/tema (dupa design-consultation, ating base.html → serializeaza intre ele):
[US-013] paleta azur ROMFAST + IBM Plex ← DESIGN.md (DONE)
[US-012] header „by ROMFAST" + titlu centrat ← (independent, dar atinge base.html → dupa US-013)
[US-014] selector tema ciclic + Petrol/Auto ← US-013
```
> Nota orchestrare: US-006, US-007, US-008 ating toate `_trimitere_detaliu.html` → serializeaza-le
> pe acelasi fisier (nu in worktree-uri paralele). La fel US-010/US-011 pe `_mapari.html`, si
> US-012/US-013/US-014 pe `base.html` (toate trei ating header/CSS/script — un singur autor secvential).
---
## Raport autoplan (review CEO + Design + Eng)
> Rulat 2026-06-25. **Codex indisponibil** (usage limit pe contul OpenAI pana 2026-07-18) →
> toate fazele degradate la **subagent-only** (`[codex-unavailable]`, single-model). Voci
> independente Claude pe fiecare faza (fara context prealabil). Constatarile tehnice critice
> verificate direct in cod de lead inainte de aplicare.
### Consensus (subagent-only — o singura voce per dimensiune)
```
CEO : Premise valide (bug US-001 confirmat real). Recomandare = split branding/tema.
DESIGN : Tare pe backend; lacune in stari vizuale + a11y controale noi (emoji, ciclu tema, pill).
ENG : 4 must-fix verificate in cod (C1 _is_iso_date prefix; H1 total post-filtru;
H3 US-006 nu e reuse pur; M4 card 3n in afara ramurii editabil).
```
### Cross-phase themes (semnalate de 2+ voci independent)
- **US-006 e cel mai riscant story** — CEO (riscant, confirma calea) + Eng (H3: NU e reuse pur, cere logica noua + validare nomenclator + assert pe cheia idempotency). Aplicat in AC.
- **A11y a controalelor noi** — Design (emoji, ciclu tema, pill fara semantica) e clasa de probleme pe care TDD pe substring Jinja o trece dar livreaza un experience rupt la tastatura/screen-reader. Aplicat (SVG/text, aria-pressed, aria-live).
- **Triplu-encoding contoare problema** — Design (pills + „Necesita atentia ta" + badge Mapari). Aplicat: pills inlocuiesc link-urile.
### Decizii auto (principiile 6) — must-fix aplicate in AC
| # | Faza | Decizie | Clasificare | Principiu | Rationament |
|---|------|---------|-------------|-----------|-------------|
| 1 | Eng | US-001: `_iso_date_prefix` (garda+comparatie pe `[:10]`) | Mecanic | P1/P5 | Fara asta bug-ul ramane; o singura solutie corecta |
| 2 | Eng | US-004: total ramificat (SQL count fara filtru Python / fetch-all+slice cu filtru) | Mecanic | P1 | Contradictie Fisiere↔AC; SQL COUNT gresit cu filtru Python |
| 3 | Eng | US-004: clamp `page`, ascunde pager la `pages<=1`, poll pastreaza pagina | Mecanic | P1 | Edge-cases + regresie poll |
| 4 | Eng | US-006: camp `cod_prestatie` + validare nomenclator + assert cheie idempotency (NU reuse pur) | Mecanic | P1/P5 | Verificat in `routes.py:1009-1012` |
| 5 | Eng | US-002/007: cheie payload distincta + conventie goala (`—` vs None) | Mecanic | P5 | Chei suprapuse azi; conventie ambigua intre stories |
| 6 | Eng | US-008: muta cardul 3n in `{% if not editabil %}` + erori fara camp | Mecanic | P1 | Cardul e azi in afara ramurii editabil |
| 7 | Design | US-003: pill-uri butoane reale (aria-pressed), inlocuiesc link-urile, matrice culori categorie (nu accent), sincron cu dropdown | Mecanic | P1 | A11y + anti triplu-encoding |
| 8 | Design | US-009: scoate schela `role="tablist"` orfana + JS | Mecanic | P1 | Un tab in tablist = violare ARIA |
| 9 | Design | US-011: icon-uri SVG/text (nu emoji brute), dirty-state concret, 44px scopat | Taste→aplicat | P1/P5 | User a scris emoji; SVG satisface intentia + a11y + recolorare (vezi poarta) |
| 10 | Design | US-012/013/014: env badge in grila; FOUT pe tabulare; legacy `localStorage.theme`; ciclu a11y; wordmark pe 3 teme | Mecanic | P1 | Stari vizuale lipsa |
### Decizii de taste / User Challenge — la poarta (NU auto-decise)
- **UC-1 (CEO, single-voice)**: split branding/tema (US-012/013/014) intr-un PRD separat + taie Petrol & selectorul ciclic. **Default = PASTRAM** (user a ales explicit in sesiune: Petrol ca tema, ciclu ca demoanaf, IBM Plex). CEO ruleaza ca o singura voce (codex jos) — recomandare, nu decizie.
- **UC-2 (CEO)**: paginarea (US-004) poate fi prematura la scara actuala (purge 90z). **Default = PASTRAM** (alegere user); cost low (1 query de validat numarul max randuri/cont inainte de build).
- **T-1 (Design A1)**: emoji 💾/🗑 (scrise de user) → SVG/text icon. Aplicat ca SVG (satisface ambele), surfata la poarta fiindca user a scris emoji literal.
### Deferat (TODOS, nu in 5.10)
- Hardening GET-uri de listare globale/neprotejate (CLAUDE.md, semnalat de CEO ca valoare mai mare) — separat, nu expandam acest PRD.
- Validare empirica „>200 randuri/cont" pentru US-004 si „cerere reala >2 teme" pentru US-014.
### Verdict poarta (2026-06-25)
- **UC-1 → PASTRAM tot in 5.10** (14 stories, functional + branding/tema). User a confirmat la poarta; CEO single-voice = recomandare, nu verdict. Riscul FOUC/FOUT mitigat prin AC-urile adaugate.
- **UC-2 → PASTRAM paginarea** (US-004). Cost mic, specificata corect dupa review.
- **T-1 → icon-uri SVG/text** (nu emoji brute), aplicat in US-011.
- **APROBAT.** Toate must-fix-urile aplicate in AC. Gata de executie (TDD prin echipa, vezi valuri §6) sau `/ralph`.
---
## Raport VERIFY
> Completat de subagentul verificator (context curat, fara transcriptul executiei) — ROADMAP §5.6. 2026-06-25.
**Verdict: PASS** — toate cele 14 stories PASS, 0 FAIL. Regresie completa **892 passed / 1 skipped / 0 failed** (+49 teste fata de baseline 843; skipped = test `live` RAR, opt-in). Backend trimitere (worker/masina stari/idempotenta/mapping) + schema-send NEATINSE (Non-Goals respectate).
PASS per story cu dovezi verificate direct in cod:
- US-001 PASS — `_iso_date_prefix` folosit ATAT pentru garda CAT si comparatie (`routes.py`), interval inclusiv, ne-ISO excluse.
- US-002 PASS — chei distincte `op_service_cod`/`op_service_denumire`, conventie goala `""` (nu `—`).
- US-003 PASS — pill-uri `<button>` cu `aria-pressed`; needs_mapping `--warn`, needs_data/error `--err`; dropdown status eliminat (fara desync); lista ID-uri eliminata.
- US-004 PASS — total ramificat (SQL COUNT fara filtru Python / fetch-all+slice cu filtru); clamp page; poll pastreaza pagina (OOB `f-page`); pager ascuns la `pages<=1`.
- US-005 PASS — VIN element block-level (`<div>`) sub nr, garda VIN lipsa.
- US-006 PASS — logica noua in `post_corectie_trimitere` (citeste `cod_prestatie`, valideaza nomenclator, injecteaza in `content["prestatii"][0]` inainte de `resolve_prestatii`); cheia idempotency difera; read-only pe sent/sending/queued.
- US-007 PASS — Operatie service distinct de Operatie RAR, absent cand lipseste, in editabil + read-only.
- US-008 PASS — card 3n in `{% if not editabil %}`; erori fara camp = rezumat top-of-form in editare.
- US-009 PASS — Mapari in `#cont-menu` cu badge; tab-bar + `role=tablist` + JS sageti eliminate; `?tab=mapari` nu da 404.
- US-010 PASS — ordine sectiuni corecta; `ajutor-mapari` + empty-text eliminate.
- US-011 PASS — butoane `.icon-btn` SVG (fara emoji); kebab eliminat; dirty-state pe `change` select; `hx-confirm` pastrat.
- US-012 PASS — header grid 3col centrat; env badge in grila; wordmark `by ROMFAST` (ROM `#D1342F`, FAST `#2E74D6`) text, nu PNG.
- US-013 PASS — accent `#2E74D6`/`#1F66C9`/`#0E7C7B`; `@font-face` IBM Plex Sans/Mono. **Rezerva fonturi woff2 placeholder REZOLVATA post-VERIFY**: cele 8 fisiere au fost inlocuite cu subseturile reale IBM Plex (fontsource `@fontsource/ibm-plex-*@5.0.8`, woff2 valide latin + latin-ext, 400/500/700 sans + 400 mono).
- US-014 PASS — `[data-theme=petrol]` `#0E7C7B`; ciclu Light->Dark->Petrol->Auto; anti-FOUC extins la 4 stari + fallback legacy; `aria-label` curenta+urmatoarea + `aria-live`.
**Limitari documentate:**
- E2E browser NEPROBAT in sandbox (fara browser interactiv, consistent cu 5.8/5.9). Recomandat la deploy: `./start.sh test both --send` + browser pe `http://localhost:8000/` pentru proba vizuala (pill-uri/click, paginare >25, VIN sub nr, ciclu tema fara blink, header centrat, Mapari din ☰).
- Live RAR `--send` neprobat (UI pur; backend trimitere neatins — risc minim).
### CLOSE — `/code-review high` (2026-06-25)
1 finding material reparat TDD (US-006b): US-006 acoperea doar `needs_data`/`needs_mapping`, dar AC + intrebarea deschisa cer si `error`. Fix: constanta separata `_EDITABILE_OP=(needs_data,needs_mapping,error)` pentru selectul de cod RAR; pe `error` editarea codului trece prin `/repune` (re-queue), cu validare nomenclator, re-rezolvare, canonicalize + `build_key` (cheie idempotency noua), check coliziune (pre-UPDATE + `IntegrityError`), `error→queued` (rar_error NULL / retry_count 0). `_CORECTABILE` + `post_corectie_trimitere` neatinse (fara regresie US-010/US-011). 9/9 teste editare; regresie completa **896 passed / 1 skipped / 0 failed**.
Findings minore (debt acceptat, non-blocante): (a) comparatie data bruta `d_prefix > data_pana` — sigura prin UI (`<input type=date>` zero-padded), vulnerabila doar la URL fabricat; (b) dublu `load_nomenclator` cand nomenclatorul e legitim gol; (c) linkurile de paginare reafiseaza `vehicul` cu majuscule (cosmetic); (d) duplicare cale validare+inject+idempotency intre `post_corectie_trimitere` si `post_repune_trimitere` (candidat de extras intr-un helper).
**US-012b (decizie user post-review):** in header se foloseste LOGO-ul PNG real (`/static/romfast_logo.png`, `.brand-logo` ~28px) in loc de wordmark-ul text din US-012. Fundal transparent + culori proprii -> lizibil pe toate temele. `test_web_header_branding.py` actualizat sa verifice `<img romfast_logo.png>` + `alt`. DESIGN.md actualizat. Regresie ramane 896 passed.
**Stare finala: VERIFY PASS + fix code-review (US-006b) + logo header (US-012b) aplicate. Gata de commit (poarta umana).**

BIN
docs/romfast_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -50,14 +50,16 @@ def _seed_submission(status: str = "sent", n: int = 1) -> None:
def test_tab_bar_fara_trimiteri(client):
"""Tab-bar-ul nu mai contine tab-ul 'Trimiteri' (coada); raman 4 tab-uri."""
"""US-009: tab-bar eliminat; 'Coada' nu exista; Mapari/Cont/Nomenclator raman in meniu."""
r = client.get("/")
assert r.status_code == 200
html = r.text
# "Coada" nu trebuie sa existe nici ca tab, nici ca link in meniu
assert 'id="tab-coada"' not in html
assert 'href="/?tab=coada"' not in html
for label in ("Acasa", "Mapari", "Cont", "Nomenclator"):
assert f">{label}" in html or f"{label}<" in html, f"lipseste tab {label}"
# US-009: tab-bar eliminat; Mapari/Cont/Nomenclator sunt in meniul hamburger
for label in ("Mapari", "Cont", "Nomenclator"):
assert f">{label}" in html or f"{label}<" in html, f"lipseste intrarea {label} in meniu"
def test_acasa_contine_sectiunea_trimiteri(client):
@@ -126,10 +128,19 @@ def test_badge_trimiteri_scoped_pe_acasa(client):
assert "2" in html[idx:idx + 400]
def test_trimiteri_poll_aliniat_15s(client):
"""Poll-ul de trimiteri e aliniat la 15s (anti dublu-poll M5), nu 10s."""
def test_trimiteri_fara_poll_periodic_pe_tabel(client):
"""Tabelul de trimiteri NU se mai reimprospateaza periodic: #submissions-wrap se
incarca la load / actiunile utilizatorului / Reincarca (nudge), fara `every Ns`.
Reimprospatarea live se face prin nudge-ul "Date noi" + endpointul de versiune."""
_seed_submission("sent")
r = client.get("/?tab=acasa")
html = r.text
assert "every 15s" in html
assert "every 10s" not in html
# Trigger-ul tabelului nu contine poll periodic.
wrap = html[html.find('id="submissions-wrap"'):]
wrap = wrap[:wrap.find(">") + 1]
assert "every" not in wrap, f"tabelul nu trebuie sa aiba poll periodic: {wrap}"
assert "reincarcaTrimiteri" in wrap
assert "trimiteriChanged" in wrap
# Mecanismul de nudge exista (banner + endpoint versiune).
assert 'id="nudge-trimiteri"' in html
assert "/_fragments/trimiteri-versiune" in html

View File

@@ -146,3 +146,69 @@ def test_operatie_ramane_denumire_sau_op():
})
assert d3["operatie"] == "Verificare"
assert d3["cod"] == "oe-2" # cod ramas neschimbat (nu uppercase)
# --- US-002: op_service_cod + op_service_denumire distincte ---
def test_operatie_service_din_cod_op_service():
"""cod_op_service prezent -> op_service_cod contine valoarea; string gol cand lipseste."""
# cod_op_service prezent -> op_service_cod populated
d = prezentare_din_payload({
"prestatii": [{"cod_op_service": "OP-77", "denumire": "Verificare faruri"}]
})
assert d["op_service_cod"] == "OP-77"
# cod_op_service absent (vine direct cu cod_prestatie) -> op_service_cod == "" (NU EMPTY="—")
d2 = prezentare_din_payload({
"prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}]
})
assert d2["op_service_cod"] == ""
assert d2["op_service_cod"] != ""
def test_operatie_service_din_denumire():
"""denumire prezenta cu cod_op_service -> op_service_denumire contine valoarea."""
# ambele prezente -> op_service_denumire = denumire
d = prezentare_din_payload({
"prestatii": [{"cod_op_service": "OP-77", "denumire": "Verificare faruri"}]
})
assert d["op_service_denumire"] == "Verificare faruri"
# cod_op_service prezent dar fara denumire -> op_service_denumire == ""
d2 = prezentare_din_payload({
"prestatii": [{"cod_op_service": "OP-77"}]
})
assert d2["op_service_denumire"] == ""
# cod_op_service absent + denumire prezenta -> op_service_denumire == "" (nu expune denumire RAR)
d3 = prezentare_din_payload({
"prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}]
})
assert d3["op_service_denumire"] == ""
def test_fara_operatie_service_cand_lipseste():
"""Payload fara cod_op_service -> op_service_cod si op_service_denumire sunt "" (nu ""), fara exceptie."""
# vine direct cu cod_prestatie
d = prezentare_din_payload({
"prestatii": [{"cod_prestatie": "R-FRANE"}]
})
assert d["op_service_cod"] == ""
assert d["op_service_denumire"] == ""
assert d["op_service_cod"] != ""
assert d["op_service_denumire"] != ""
# fara prestatii deloc
d2 = prezentare_din_payload({"vin": "WVWZZZ1JZXW000001"})
assert d2["op_service_cod"] == ""
assert d2["op_service_denumire"] == ""
# payload None/gol
d3 = prezentare_din_payload(None)
assert d3["op_service_cod"] == ""
assert d3["op_service_denumire"] == ""
# payload JSON invalid
d4 = prezentare_din_payload("nu-e-json")
assert d4["op_service_cod"] == ""
assert d4["op_service_denumire"] == ""

View File

@@ -71,13 +71,15 @@ def test_paleta_light_definita(client):
def test_dark_ramane_default(client):
""":root contine inca paleta dark exacta: --bg:#0f1115, --card:#181b22, --ink:#e6e9ef."""
""":root contine paleta dark exacta: --bg:#0f1218, --card:#181c24, --ink:#e6e9ef.
Valorile actualizate la US-013 (PRD 5.10) conform DESIGN.md (accent azur ROMFAST).
"""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert "--bg:#0f1115" in html, "Paleta dark --bg:#0f1115 a fost modificata sau stearsa"
assert "--card:#181b22" in html, "Paleta dark --card:#181b22 a fost modificata sau stearsa"
assert "--bg:#0f1218" in html, "Paleta dark --bg:#0f1218 a fost modificata sau stearsa"
assert "--card:#181c24" in html, "Paleta dark --card:#181c24 a fost modificata sau stearsa"
assert "--ink:#e6e9ef" in html, "Paleta dark --ink:#e6e9ef a fost modificata sau stearsa"

View File

@@ -74,7 +74,7 @@ def client(monkeypatch):
def test_badge_mapari(client):
"""Cu operatii needs_mapping, tab-ul Mapari poarta un numar + aria-label."""
"""US-009: cu operatii needs_mapping, intrarea Mapari din meniu poarta un badge cu numar."""
acct = _create_account_user("bm@test.com")
_ins(acct, "needs_mapping")
_ins(acct, "needs_mapping")
@@ -82,10 +82,14 @@ def test_badge_mapari(client):
resp = client.get("/")
assert resp.status_code == 200
link = _tab_link(resp.text, "tab-mapari")
assert "tab-badge" in link
assert "2" in link
assert "necesita atentie" in link # aria-label
html = resp.text
# US-009: Mapari e acum in meniu (nu tab); badgeul apare in intrarea meniului
idx = html.find('href="/?tab=mapari"')
assert idx != -1, "Intrarea Mapari lipseste din meniu"
# Cauta badgeul in contextul link-ului Mapari
window = html[idx:idx + 300]
assert "tab-badge" in window, "Badgeul (tab-badge) trebuie sa apara langa intrarea Mapari"
assert "2" in window, "Contorul 2 trebuie sa apara in badge-ul Mapari"
def test_badge_trimiteri_blocate(client):

View File

@@ -85,9 +85,7 @@ def test_tab_import_redirect(client):
html = resp.text
# Echivalent Acasa: contine upload-ul (import-section)
assert 'id="import-section"' in html
# Acasa e tab-ul activ (import nu mai e tab valid separat)
assert re.search(r'id="tab-acasa"[^>]*aria-selected="true"', html), \
"?tab=import ar trebui sa cada pe Acasa activ"
# US-009: tab-bar eliminat, nu mai exista tab-uri cu aria-selected
def test_tab_bar_fara_import(client):

View File

@@ -0,0 +1,207 @@
"""Teste US-008 (PRD 5.10): simplificare erori in formularul de editare.
Problema actuala: cardul erori_3n/card_erori (clasa eroare-3n) e randat INAINTE de form,
in afara ramurii `{% if editabil %}` — deci apare si in contextul de editare.
US-008 cere:
- In editare: cardul 3-niveluri (`eroare-3n`) DISPARUT; erori per-camp raman ca text simplu
subliniat (.s-error); erori fara camp (field None) apar ca rezumat simplu top-of-form.
- In read-only: cardul 3-niveluri se pastreaza (comportament existent).
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post(
"/login",
data={"email": email, "parola": password, "csrf_token": m.group(1)},
)
assert resp.status_code == 303
def _csrf(client) -> str:
resp = client.get("/")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m
return m.group(1)
def _insert(acct: int, *, status: str, rar_error: str | None = None,
vin: str = "WVWZZZ1JZXW000099", nr: str = "B100TST") -> int:
from app.db import get_connection
conn = get_connection()
try:
payload = json.dumps({
"vin": vin,
"nr_inmatriculare": nr,
"data_prestatie": "2026-06-20",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "OE-1"}],
})
cur = conn.execute(
"INSERT INTO submissions "
"(idempotency_key, account_id, status, payload_json, rar_error) "
"VALUES (?, ?, ?, ?, ?)",
(f"k-{os.urandom(6).hex()}", acct, status, payload, rar_error),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "eroare_simpla.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_form_editare_fara_card_3niveluri(client):
"""In contextul editabil (needs_data), cardul erori_3n (eroare-3n) NU apare.
Problema curenta: cardul e randat INAINTE de form, in afara ramurii editabil,
deci apare atat in editare cat si read-only. US-008 il muta in `{% if not editabil %}`.
"""
acct = _create_account_user("edit3n@test.com")
# needs_data cu rar_error care contine o eroare cu field — format {field, message}
rar_error = json.dumps([
{"field": "odometruFinal", "message": "Odometru trebuie sa fie un numar intreg (ca string)."}
])
sid = _insert(acct, status="needs_data", rar_error=rar_error)
_login(client, "edit3n@test.com")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# In editare: cardul cu 3 niveluri NU trebuie sa apara
assert 'class="eroare-3n"' not in html, (
"Cardul erori_3n (eroare-3n) NU trebuie sa apara in contextul editabil (needs_data). "
"US-008: muta-l in '{% if not editabil %}'."
)
assert 'eroare-3n-item' not in html, (
"Itemii card-ului 3n (eroare-3n-item) nu trebuie sa apara in editare."
)
# Formularul de editare trebuie sa ramana prezent
assert 'hx-post=' in html and 'corecteaza' in html, "Formularul de corectie trebuie sa existe"
def test_eroare_pe_camp_doar_text_simplu(client):
"""Dupa o corectie invalida, eroarea per-camp apare ca .s-error text simplu, nu card 3n.
Macro-ul `camp` deja printeaza doar mesajul simplu — testul verifica ca
`eroare-3n` nu exista in raspuns (nu e dobla-randat odata prin card si odata prin macro).
"""
acct = _create_account_user("simplu@test.com")
sid = _insert(acct, status="needs_data", vin="WVWZZZ1JZXW000001", nr="B100TST")
_login(client, "simplu@test.com")
csrf = _csrf(client)
# POST cu odometru invalid (non-numeric) — ramane needs_data + eroare per-camp
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"nr_inmatriculare": "B100TST",
"vin": "WVWZZZ1JZXW000001",
"data_prestatie": "2026-06-20",
"odometru_final": "nu-e-numar",
"csrf_token": csrf,
},
)
assert resp.status_code == 200
html = resp.text
# Eroarea per-camp trebuie sa apara ca text simplu (.s-error)
assert 's-error' in html, "Eroarea per-camp trebuie sa apara ca .s-error text simplu"
# Cardul 3-niveluri NU trebuie sa apara in contextul editabil
assert 'class="eroare-3n"' not in html, (
"Cardul eroare-3n NU trebuie sa apara in contextul editabil. "
"US-008: randat doar in read-only ({% if not editabil %})."
)
def test_eroare_fara_camp_apare_ca_rezumat_in_editare(client):
"""Erori cu field=None nu dispar silentios in editare — apar ca rezumat simplu top-of-form.
Bug M6: template-ul filtra erorile in `err_map` DOAR pe cele cu field,
iar cardul 3n (ascuns in editare) era singurul canal de afisare pentru field=None.
US-008: adauga un rezumat simplu (div .s-error sau similar) in ramura editabil.
"""
acct = _create_account_user("faracam@test.com")
# rar_error cu o eroare FARA camp (field=None) — ex. eroare globala de la RAR
rar_error = json.dumps([
{"problema": "Date incomplete la nivel de prezentare", "cauza": "", "fix": "", "field": None}
])
sid = _insert(acct, status="needs_data", rar_error=rar_error)
_login(client, "faracam@test.com")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Mesajul erorii globale trebuie sa fie prezent (nu silentios disparut)
assert "Date incomplete la nivel de prezentare" in html, (
"Eroarea fara camp (field=None) trebuie sa apara in contextul editabil. "
"US-008 (M6): adauga rezumat simplu top-of-form in ramura '{% if editabil %}'."
)
# Dar NU ca card 3-niveluri
assert 'class="eroare-3n"' not in html, (
"Cardul eroare-3n NU trebuie sa apara in contextul editabil."
)
def test_readonly_pastreaza_card_3niveluri(client):
"""In contextul read-only (error/sent), cardul erori_3n se pastreaza neschimbat."""
acct = _create_account_user("readonly3n@test.com")
rar_error = json.dumps([
{"problema": "Eroare RAR server", "cauza": "ORA-12899", "fix": "Reverifica datele", "field": None}
])
sid = _insert(acct, status="error", rar_error=rar_error)
_login(client, "readonly3n@test.com")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# In read-only: cardul eroare-3n TREBUIE sa apara
assert 'class="eroare-3n"' in html, (
"Cardul erori_3n (eroare-3n) trebuie pastrat in contextul read-only (error)."
)

View File

@@ -0,0 +1,191 @@
"""Teste US-007 (PRD 5.10): afisare operatie de service in detaliu.
Cand payload-ul contine `cod_op_service` (codul intern al service-ului), detaliul
trebuie sa afiseze „Operatie service" (cod + denumire), distinct de „Operatie RAR".
Cand lipseste (a venit direct cu `cod_prestatie`), randul nu apare deloc — conventie
"" (string gol) stabilita de US-002 in payload_view.py.
Apare atat in contextul editabil (needs_data/needs_mapping) cat si read-only (sent/error).
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post(
"/login",
data={"email": email, "parola": password, "csrf_token": m.group(1)},
)
assert resp.status_code == 303
def _insert(acct: int, *, status: str, payload: dict) -> int:
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(f"k-{os.urandom(6).hex()}", acct, status, json.dumps(payload)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _payload_cu_op_service(vin: str = "WVWZZZ1JZXW000001") -> dict:
"""Payload cu cod_op_service + denumire (vine prin API cu cod intern)."""
return {
"vin": vin,
"nr_inmatriculare": "B100TST",
"data_prestatie": "2026-06-20",
"odometru_final": "50000",
"prestatii": [
{
"cod_op_service": "OP-FRANE-77",
"denumire": "Verificare si reglaj frane",
"cod_prestatie": "OE-1", # mapat deja
}
],
}
def _payload_fara_op_service(vin: str = "WVWZZZ1JZXW000002") -> dict:
"""Payload cu cod_prestatie direct (fara cod_op_service)."""
return {
"vin": vin,
"nr_inmatriculare": "B200TST",
"data_prestatie": "2026-06-20",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "op_service_test.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_detaliu_arata_operatie_service_read_only(client):
"""In context read-only (sent), operatia de service (cod intern + denumire) apare distinct.
Randul „Operatie service" trebuie sa fie vizibil si sa contina codul intern
si denumirea venita prin API, separat de „Operatie RAR".
"""
acct = _create_account_user("op_srv_ro@test.com")
sid = _insert(acct, status="sent", payload=_payload_cu_op_service())
_login(client, "op_srv_ro@test.com")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Codul intern de service trebuie sa apara
assert "OP-FRANE-77" in html, (
"Codul intern al operatiei de service (op_service_cod) trebuie afisat in detaliu read-only."
)
# Denumirea trebuie sa apara
assert "Verificare si reglaj frane" in html, (
"Denumirea operatiei de service (op_service_denumire) trebuie afisata in detaliu read-only."
)
# Eticheta „Operatie service" trebuie sa apara
assert "Operatie service" in html, (
"Eticheta 'Operatie service' trebuie sa apara in detaliu read-only."
)
def test_detaliu_arata_operatie_service_editabil(client):
"""In context editabil (needs_data), operatia de service apare de asemenea.
Campul este read-only in forma (nu e editabil de operator), dar trebuie afisat
ca referinta pentru ce a cerut service-ul.
"""
acct = _create_account_user("op_srv_edit@test.com")
sid = _insert(acct, status="needs_data", payload=_payload_cu_op_service())
_login(client, "op_srv_edit@test.com")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Codul intern trebuie sa apara si in editare
assert "OP-FRANE-77" in html, (
"Codul intern al operatiei de service trebuie afisat si in contextul editabil (needs_data)."
)
# Eticheta trebuie sa apara
assert "Operatie service" in html, (
"Eticheta 'Operatie service' trebuie sa apara si in contextul editabil."
)
def test_detaliu_omite_cand_lipseste_read_only(client):
"""Cand payload-ul nu are cod_op_service (vine direct cu cod_prestatie), randul nu apare.
Conventie US-002: op_service_cod = "" (nu ""). Randul lipseste complet
(nu apare „Operatie service: —" sau rand gol).
"""
acct = _create_account_user("op_srv_absent_ro@test.com")
sid = _insert(acct, status="sent", payload=_payload_fara_op_service())
_login(client, "op_srv_absent_ro@test.com")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Eticheta „Operatie service" nu trebuie sa apara cand lipseste
assert "Operatie service" not in html, (
"Randul 'Operatie service' nu trebuie sa apara cand payload-ul nu contine cod_op_service. "
"Conventie US-002: op_service_cod='' → rand absent complet."
)
def test_detaliu_omite_cand_lipseste_editabil(client):
"""Cand payload-ul nu are cod_op_service, randul nu apare nici in editare."""
acct = _create_account_user("op_srv_absent_edit@test.com")
sid = _insert(acct, status="needs_data", payload=_payload_fara_op_service())
_login(client, "op_srv_absent_edit@test.com")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
assert "Operatie service" not in html, (
"Randul 'Operatie service' nu trebuie sa apara in editare cand lipseste cod_op_service."
)

View File

@@ -0,0 +1,359 @@
"""Teste US-006 (PRD 5.10): editare operatie RAR (cod_prestatie) din formularul de detaliu.
Stari editabile: needs_data, needs_mapping (stari cu formular de corectie activ).
Read-only: sent/sending/queued/error (fara select cod_prestatie).
Cazuri:
- test_editabil_arata_select_cod_rar: detaliu needs_data → HTML are <select name="cod_prestatie">
- test_salvare_schimba_cod_si_repune_in_coada: POST cu cod_prestatie=OE-2 → payload actualizat + status queued
- test_idempotency_key_se_schimba: schimbarea codului → cheie idempotency noua
- test_cod_invalid_respins: cod necunoscut in nomenclator → respins (status neschimbat)
- test_sent_nu_arata_select: detaliu sent → fara <select name="cod_prestatie">
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# VIN valid: 17 caractere, fara I/O/Q
VIN_US006 = "WVWZZZ1JZXW0E6001"
# Payload complet valid (trece validate_prezentare)
PAYLOAD_VALID = {
"vin": VIN_US006,
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "55000",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _ins(acct: int, *, status: str, payload: dict | None = None) -> 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-us006-{os.urandom(6).hex()}", acct, status, json.dumps(payload or PAYLOAD_VALID)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _ins_nomenclator(*codes: str) -> None:
"""Insereaza coduri RAR in nomenclator_rar (tabelul e gol in DB-ul de test)."""
from app.db import get_connection
conn = get_connection()
try:
for cod in codes:
conn.execute(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, f"Operatie test {cod}"),
)
conn.commit()
finally:
conn.close()
def _row(sid: int):
from app.db import get_connection
conn = get_connection()
try:
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
finally:
conn.close()
def _csrf(client) -> str:
"""CSRF token din pagina principala (sesiune activa necesara)."""
resp = client.get("/")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m, f"CSRF token negasit in pagina principala: {resp.text[:500]}"
return m.group(1)
def _detaliu(client, sid: int) -> str:
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
return resp.text
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "editare_rar.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_editabil_arata_select_cod_rar(client):
"""needs_data cu nomenclator populat → formularul de detaliu afiseaza <select name='cod_prestatie'>."""
acct = _create_account_user("sel1@test.com")
_ins_nomenclator("OE-1", "OE-2")
sid = _ins(acct, status="needs_data")
_login(client, "sel1@test.com")
html = _detaliu(client, sid)
assert 'name="cod_prestatie"' in html, (
"Formularul de detaliu needs_data trebuie sa contina un select cu name='cod_prestatie'"
)
assert "<select" in html, (
"Elementul <select> trebuie sa apara in detaliu pentru starea needs_data"
)
# Optiunile din select contin codurile din nomenclator
assert "OE-1" in html, "Codul OE-1 trebuie sa apara in optiunile select-ului"
assert "OE-2" in html, "Codul OE-2 trebuie sa apara in optiunile select-ului"
def test_salvare_schimba_cod_si_repune_in_coada(client):
"""POST /corecteaza cu cod_prestatie=OE-2 → payload actualizat + status=queued."""
acct = _create_account_user("sav2@test.com")
_ins_nomenclator("OE-1", "OE-2")
sid = _ins(acct, status="needs_data")
_login(client, "sav2@test.com")
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "OE-2"},
)
assert resp.status_code == 200
row = _row(sid)
assert row["status"] == "queued", (
f"Dupa salvarea cu OE-2, randul trebuie sa fie queued, nu '{row['status']}'"
)
payload = json.loads(row["payload_json"])
prestatii = payload.get("prestatii") or []
assert prestatii, "Payload-ul trebuie sa contina cel putin o prestatie dupa corectie"
cod_nou = (prestatii[0].get("cod_prestatie") or "").strip().upper()
assert cod_nou == "OE-2", (
f"cod_prestatie in payload trebuie sa fie OE-2, nu '{cod_nou}'"
)
def test_idempotency_key_se_schimba(client):
"""Schimbarea cod_prestatie (OE-1 → OE-2) recalculeaza cheia de idempotency.
Verificare stricta: cheia calculata dupa POST cu OE-2 difera de cheia CANONICALA
cu OE-1. Daca cod_prestatie nu e injectat inainte de build_key, cheia ramane cea
cu OE-1 si testul pica. Este RED inainte de implementarea US-006.
"""
from app.idempotency import build_key, canonicalize_row
acct = _create_account_user("idem3@test.com")
_ins_nomenclator("OE-1", "OE-2")
sid = _ins(acct, status="needs_data")
_login(client, "idem3@test.com")
csrf = _csrf(client)
# Cheia canonicala cu OE-1 = ce ar produce un POST fara cod_prestatie (sau cu OE-1)
canon_oe1 = canonicalize_row({**PAYLOAD_VALID, "prestatii": [{"cod_prestatie": "OE-1"}]})
key_oe1 = build_key(acct, canon_oe1)
# POST cu cod_prestatie=OE-2: implementarea US-006 trebuie sa injecteze OE-2 in payload
client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "OE-2"},
)
cheie_noua = _row(sid)["idempotency_key"]
assert cheie_noua != key_oe1, (
"Cheia calculata dupa POST cu OE-2 trebuie sa difere de cheia cu OE-1. "
"Daca sunt egale, cod_prestatie nu a fost injectat inainte de build_key (US-006 H3)."
)
def test_cod_invalid_respins(client):
"""Cod RAR necunoscut in nomenclator → randul ramane needs_data (nu se re-cueaza)."""
acct = _create_account_user("inv4@test.com")
_ins_nomenclator("OE-1") # "ZZ-INVALID" nu exista
sid = _ins(acct, status="needs_data")
_login(client, "inv4@test.com")
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "ZZ-INVALID"},
)
# Raspunsul e 200 (eroare in pagina, nu redirect)
assert resp.status_code == 200
row = _row(sid)
assert row["status"] == "needs_data", (
f"Un cod invalid trebuie sa lase randul in needs_data, nu '{row['status']}'"
)
# Mesajul de eroare trebuie sa apara in raspuns
assert "ZZ-INVALID" in resp.text or "necunoscut" in resp.text.lower(), (
"Raspunsul trebuie sa indice ca codul este necunoscut in nomenclator"
)
def test_sent_nu_arata_select(client):
"""Trimitere cu status=sent → fara <select name='cod_prestatie'> in detaliu (read-only)."""
acct = _create_account_user("ro5@test.com")
_ins_nomenclator("OE-1", "OE-2")
sid = _ins(acct, status="sent")
_login(client, "ro5@test.com")
html = _detaliu(client, sid)
assert 'name="cod_prestatie"' not in html, (
"Starea sent trebuie sa fie read-only (fara select cod_prestatie)"
)
# ================================================================
# US-006b: extindere la starea error
# ================================================================
def test_error_arata_select_cod_rar(client):
"""needs_data/needs_mapping primeau select (US-006); error trebuie sa primeasca si el
un select cod_prestatie in formularul 'Re-pune in coada' (US-006b)."""
acct = _create_account_user("err1@test.com")
_ins_nomenclator("OE-1", "OE-2")
sid = _ins(acct, status="error")
_login(client, "err1@test.com")
html = _detaliu(client, sid)
assert 'name="cod_prestatie"' in html, (
"Starea error trebuie sa afiseze un select cod_prestatie (US-006b)"
)
assert "<select" in html, "Elementul <select> trebuie sa apara in detaliu pentru error"
# Codurile din nomenclator trebuie sa fie in optiuni
assert "OE-1" in html and "OE-2" in html, (
"Codurile din nomenclator trebuie sa apara in select-ul pentru error"
)
# NU trebuie sa afiseze formularul complet de corectie (fara /corecteaza)
assert f"/trimitere/{sid}/corecteaza" not in html, (
"Starea error NU trebuie sa aiba formular /corecteaza (US-006b foloseste /repune)"
)
# Butonul principal ramane 'Re-pune in coada' (nu 'Salveaza si retrimite')
assert "Re-pune in coada" in html
assert "Salveaza si retrimite" not in html
def test_error_salvare_schimba_cod_si_repune_in_coada(client):
"""POST /repune cu cod_prestatie=OE-2 pe un rand error → payload actualizat + status=queued."""
acct = _create_account_user("err2@test.com")
_ins_nomenclator("OE-1", "OE-2")
sid = _ins(acct, status="error")
_login(client, "err2@test.com")
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/repune",
data={"csrf_token": csrf, "cod_prestatie": "OE-2"},
)
assert resp.status_code == 200
row = _row(sid)
assert row["status"] == "queued", (
f"Dupa repune cu OE-2, randul trebuie sa fie queued, nu '{row['status']}'"
)
payload = json.loads(row["payload_json"])
prestatii = payload.get("prestatii") or []
assert prestatii, "Payload-ul trebuie sa contina cel putin o prestatie"
cod_nou = (prestatii[0].get("cod_prestatie") or "").strip().upper()
assert cod_nou == "OE-2", (
f"cod_prestatie in payload trebuie sa fie OE-2, nu '{cod_nou}'"
)
def test_error_idempotency_key_se_schimba(client):
"""Schimbarea cod_prestatie (OE-1 → OE-2) la repune recalculeaza cheia de idempotency.
Randul e inserat CU CHEIA CANONICA pentru OE-1 (nu random), ca sa fie RED inainte
de implementare: fara injectare, repune nu schimba cheia (ramane OE-1) → test FAIL.
Dupa implementare, POST cu OE-2 → cheie noua (canonicala cu OE-2) ≠ cheie OE-1.
"""
from app.idempotency import build_key, canonicalize_row
from app.db import get_connection as _gc
acct = _create_account_user("err3@test.com")
_ins_nomenclator("OE-1", "OE-2")
# Calculeaza cheia canonicala pentru OE-1 si insereaza randul CU acea cheie.
canon_oe1 = canonicalize_row({**PAYLOAD_VALID, "prestatii": [{"cod_prestatie": "OE-1"}]})
key_oe1 = build_key(acct, canon_oe1)
# Inserare cu cheia cunoscuta (nu random), ca sa avem un baseline deterministic.
conn = _gc()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(key_oe1, acct, "error", json.dumps({**PAYLOAD_VALID, "prestatii": [{"cod_prestatie": "OE-1"}]})),
)
conn.commit()
sid = int(cur.lastrowid)
finally:
conn.close()
_login(client, "err3@test.com")
csrf = _csrf(client)
# POST /repune cu OE-2 → implementarea trebuie sa recalculeze cheia
client.post(
f"/trimitere/{sid}/repune",
data={"csrf_token": csrf, "cod_prestatie": "OE-2"},
)
cheie_noua = _row(sid)["idempotency_key"]
assert cheie_noua != key_oe1, (
"Cheia idempotency trebuie sa difere dupa schimbarea cod_prestatie la repune (US-006b). "
"Daca sunt egale, cod_prestatie nu a fost injectat inainte de build_key."
)
def test_queued_nu_arata_select(client):
"""Trimitere queued → fara select cod_prestatie (read-only; doar error/needs_* primesc select)."""
acct = _create_account_user("ro6@test.com")
_ins_nomenclator("OE-1", "OE-2")
sid = _ins(acct, status="queued")
_login(client, "ro6@test.com")
html = _detaliu(client, sid)
assert 'name="cod_prestatie"' not in html, (
"Starea queued trebuie sa fie read-only (fara select cod_prestatie)"
)

View File

@@ -0,0 +1,148 @@
"""Teste US-001 (PRD 5.10): fix filtrare pe interval de data in fragment_submissions.
Cazuri:
- timestamp-uri cu ora (ex. "2026-06-20 14:35:07") trebuie incluse la filtrare pe ziua respectiva
- interval inclusiv la ambele capete
- valori ne-ISO raman excluse
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post(
"/login",
data={"email": email, "parola": password, "csrf_token": m.group(1)},
)
assert resp.status_code == 303
def _ins(acct: int, *, status: str = "queued", vin: str, nr: str, data: str) -> int:
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(
f"k-{os.urandom(5).hex()}", acct, status,
json.dumps({
"vin": vin,
"nr_inmatriculare": nr,
"data_prestatie": data,
"odometru_final": "100",
"prestatii": [{"cod_prestatie": "R-X"}],
}),
),
)
conn.commit()
return cur.lastrowid # type: ignore[return-value]
finally:
conn.close()
def _row(sid: int) -> str:
"""Selector HTML pentru randul cu ID-ul dat."""
return f'id="trimitere-row-{sid}"'
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "filtre_data.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_filtru_data_include_timestamp_cu_ora(client):
"""Un rand cu data_prestatie = '2026-06-20 14:35:07' trebuie sa apara
cand filtrul e data_de=2026-06-20 si data_pana=2026-06-20.
Bug actual: _is_iso_date verifica len==10 → exclude timestamp → randul dispare."""
acct = _create_account_user("ts_ora@test.com")
# timestamp cu ora — asta e cauza bug-ului
sid_ora = _ins(acct, vin="WVIN001TS001ORA0001", nr="B01TS", data="2026-06-20 14:35:07")
# rand fara ora, in afara intervalului (nu trebuie sa apara)
sid_alt = _ins(acct, vin="WVIN001TS001ALT0002", nr="B02TS", data="2026-06-21")
_login(client, "ts_ora@test.com")
resp = client.get("/_fragments/submissions?data_de=2026-06-20&data_pana=2026-06-20")
assert resp.status_code == 200
assert _row(sid_ora) in resp.text, (
"Timestamp-ul cu ora trebuie inclus la filtrare pe ziua respectiva"
)
assert _row(sid_alt) not in resp.text, (
"Randul din 2026-06-21 nu trebuie sa apara in intervalul 2026-06-20 to 2026-06-20"
)
def test_filtru_data_interval_inclusiv_capete(client):
"""Intervalul data_de..data_pana este inclusiv la ambele capete.
data_de=2026-06-10, data_pana=2026-06-12 → randurile din 10, 11, 12 apar;
cel din 09 si cel din 13 nu apar.
Testat si cu timestamp-uri (ISO cu ora) pentru a combina ambele cerinte."""
acct = _create_account_user("interval@test.com")
sid_09 = _ins(acct, vin="WVIN_INTERVAL_09000", nr="B09", data="2026-06-09")
sid_10 = _ins(acct, vin="WVIN_INTERVAL_10000", nr="B10", data="2026-06-10") # capat stang inclusiv
sid_11 = _ins(acct, vin="WVIN_INTERVAL_11000", nr="B11", data="2026-06-11 08:00:00") # mijloc cu ora
sid_12 = _ins(acct, vin="WVIN_INTERVAL_12000", nr="B12", data="2026-06-12T23:59:59") # capat drept cu T
sid_13 = _ins(acct, vin="WVIN_INTERVAL_13000", nr="B13", data="2026-06-13")
_login(client, "interval@test.com")
resp = client.get("/_fragments/submissions?data_de=2026-06-10&data_pana=2026-06-12")
assert resp.status_code == 200
body = resp.text
assert _row(sid_09) not in body, "2026-06-09 e inainte de data_de → nu trebuie sa apara"
assert _row(sid_10) in body, "2026-06-10 = data_de → capatul stang inclusiv"
assert _row(sid_11) in body, "2026-06-11 cu ora trebuie inclus"
assert _row(sid_12) in body, "2026-06-12 cu T trebuie inclus (capatul drept inclusiv)"
assert _row(sid_13) not in body, "2026-06-13 e dupa data_pana → nu trebuie sa apara"
def test_filtru_data_ignora_valori_ne_data(client):
"""Valorile care nu incep cu o data ISO valida sunt excluse din rezultate
cand filtrul de data e activ — comportamentul actual pastrat."""
acct = _create_account_user("nedata@test.com")
sid_dd = _ins(acct, vin="WVIN_NEDATA_DD000001", nr="BND1", data="20.06.2026") # format DD.MM.YYYY — ne-ISO
sid_en = _ins(acct, vin="WVIN_NEDATA_EN000002", nr="BND2", data="Jun 20 2026") # format englezesc — ne-ISO
sid_bun = _ins(acct, vin="WVIN_NEDATA_BUNA0003", nr="BGD", data="2026-06-20") # format corect ISO
_login(client, "nedata@test.com")
resp = client.get("/_fragments/submissions?data_de=2026-06-20&data_pana=2026-06-20")
assert resp.status_code == 200
body = resp.text
assert _row(sid_dd) not in body, "Format DD.MM.YYYY trebuie exclus (ne-ISO)"
assert _row(sid_en) not in body, "Format englezesc trebuie exclus (ne-ISO)"
assert _row(sid_bun) in body, "Format ISO corect trebuie inclus"

View File

@@ -0,0 +1,198 @@
"""Teste US-012 / US-012b / US-012c (PRD 5.10): Header logo ROMFAST + titlu centrat.
TDD: testele se scriu INAINTE de implementare (RED), dupa implementare trec (GREEN).
US-012b (decizie user): logo PNG real (/static/romfast_logo.png) in loc de wordmark text.
US-012c (decizie user): logo mutat din header-center in header-left (brand top-left ca pe romfast.ro).
Decizie env badge: mutat in header-center (sub <h1>, mic, color:var(--muted)) — nu suprapune
logo-ul si pastreaza centrarea optica a titlului in coloana auto a grilei.
Testeaza:
- test_header_contine_by_romfast: img brand-logo in .header-left (NU in header-center)
- test_titlu_centrat: titlul e in .header-center (grila 3 coloane), controale la dreapta
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "branding.db"))
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 _get_header(html: str) -> str:
"""Extrage continutul elementului <header>."""
m = re.search(r"<header>(.*?)</header>", html, re.DOTALL | re.IGNORECASE)
assert m, "<header> negasit in HTML"
return m.group(1)
def _get_style(html: str) -> str:
"""Extrage continutul primului <style>."""
m = re.search(r"<style>(.*?)</style>", html, re.DOTALL)
assert m, "<style> negasit in HTML"
return m.group(1)
def _get_div_content(html: str, cls: str) -> str | None:
"""Extrage continutul primului div cu clasa `cls` (non-nested)."""
m = re.search(
r'<div[^>]+class=["\'][^"\']*' + re.escape(cls) + r'[^"\']*["\'][^>]*>(.*?)</div>',
html,
re.DOTALL,
)
return m.group(1) if m else None
# ── test_header_contine_by_romfast ────────────────────────────────────────────
def test_header_contine_by_romfast(client):
"""Logo-ul ROMFAST (<img class='brand-logo'>) trebuie sa fie in .header-LEFT (US-012c).
Decizie user: brand top-left ca pe romfast.ro — logo in prima celula a grilei, nu sub titlu.
Verifica:
- img cu romfast_logo.png SI class brand-logo exista in interiorul .header-left
- img cu romfast_logo.png NU exista in interiorul .header-center (a fost mutat)
- alt non-gol pe img (accesibilitate)
- .header-center NU mai contine clasele .romfast-rom / .romfast-fast (curatenie)
"""
resp = client.get("/login")
assert resp.status_code == 200
header = _get_header(resp.text)
# Extrage continutul .header-left si .header-center
left_content = _get_div_content(header, "header-left")
center_content = _get_div_content(header, "header-center")
assert left_content is not None, ".header-left lipseste din <header>"
assert center_content is not None, ".header-center lipseste din <header>"
# 1. img cu romfast_logo.png IN .header-left
img_tags_left = re.findall(r'<img[^>]+>', left_content, re.IGNORECASE)
logo_in_left = next(
(t for t in img_tags_left if "romfast_logo.png" in t),
None,
)
assert logo_in_left is not None, (
"<img> cu 'romfast_logo.png' negasit in .header-left. "
"US-012c: logo-ul trebuie sa fie in celula STANGA a grilei (brand top-left). "
f"Continut .header-left: {left_content[:400]}"
)
# 2. img cu romfast_logo.png NU mai e in .header-center
img_tags_center = re.findall(r'<img[^>]+>', center_content, re.IGNORECASE)
logo_in_center = next(
(t for t in img_tags_center if "romfast_logo.png" in t),
None,
)
assert logo_in_center is None, (
"<img> cu 'romfast_logo.png' inca e in .header-center — trebuie mutat in .header-left. "
f"Continut .header-center: {center_content[:400]}"
)
# 3. alt non-gol pe logo (accesibilitate)
alt_match = re.search(r'alt=["\']([^"\']+)["\']', logo_in_left, re.IGNORECASE)
assert alt_match and alt_match.group(1).strip(), (
"Imaginea logo din .header-left lipseste atributul alt (sau e gol). "
f"Tag gasit: {logo_in_left}"
)
# 4. class brand-logo prezent pe img
assert "brand-logo" in logo_in_left, (
"class='brand-logo' lipseste de pe <img> logo din .header-left. "
f"Tag gasit: {logo_in_left}"
)
# 5. Spanurile text (wordmark vechi) NU exista in header
assert "romfast-rom" not in header and "romfast-fast" not in header, (
"Clasele .romfast-rom / .romfast-fast (wordmark text) inca prezente in header. "
f"Header snippet: {header[:500]}"
)
# ── test_titlu_centrat ────────────────────────────────────────────────────────
def test_titlu_centrat(client):
"""Titlul 'Gateway RAR AUTOPASS' e in structura centrata in header (grila 3 coloane).
Verifica:
- CSS contine grid-template-columns cu 3 coloane pe header (1fr auto 1fr sau similar)
- Header contine un element cu clasa 'header-center' (sau similar) care contine h1
- Controalele (button tema-toggle) sunt la dreapta (in header-right sau margin-left:auto)
- header-left exista (celula stanga a grilei, contine logo dupa US-012c)
"""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
header = _get_header(html)
style = _get_style(html)
# 1. CSS header foloseste grid cu 3 coloane
header_css = re.search(r"header\s*\{([^}]+)\}", style, re.DOTALL)
assert header_css, "Regula CSS 'header { ... }' negasita in <style>"
header_block = header_css.group(1)
assert "grid" in header_block.lower(), (
f"header CSS nu foloseste grid (display:grid). Block: {header_block.strip()}"
)
assert "grid-template-columns" in style, (
"grid-template-columns lipseste din <style> (necesar pentru 3 coloane)"
)
# 2. Element centrat in header contine h1
center_div = re.search(
r'<div[^>]+class=["\'][^"\']*header-center[^"\']*["\'][^>]*>(.*?)</div>',
header,
re.DOTALL,
)
assert center_div, (
"Element cu clasa 'header-center' negasit in <header>. "
"Titlul trebuie sa fie intr-o celula centrata a grilei. "
f"Header snippet: {header[:600]}"
)
center_content = center_div.group(1)
assert "<h1" in center_content, (
"h1 nu e in interiorul elementului .header-center. "
f"Continut .header-center: {center_content[:300]}"
)
# 3. Controalele (butonul de tema) sunt in header-right
right_div = re.search(
r'<div[^>]+class=["\'][^"\']*header-right[^"\']*["\'][^>]*>(.*?)</div>',
header,
re.DOTALL,
)
assert right_div, (
"Element cu clasa 'header-right' negasit in <header>. "
"Controalele (tema, versiune, meniu) trebuie sa fie la dreapta. "
f"Header snippet: {header[:600]}"
)
right_content = right_div.group(1)
assert "tema-toggle" in right_content, (
"Butonul tema-toggle nu e in .header-right. "
f"Continut .header-right: {right_content[:300]}"
)
# 4. header-left exista in grila (contine logo dupa US-012c)
left_div = re.search(
r'<div[^>]+class=["\'][^"\']*header-left[^"\']*["\'][^>]*>(.*?)</div>',
header,
re.DOTALL,
)
assert left_div, (
"Element cu clasa 'header-left' negasit in <header>. "
f"Header snippet: {header[:600]}"
)

View File

@@ -0,0 +1,141 @@
"""Teste US-011 (PRD 5.10): butoane icon salvare/stergere + dirty state pe Mapari.
Cerinte:
- Butoane .icon-btn mereu vizibile pe rand (nu ascunse in kebab)
- Meniu kebab (<details class="kebab">) eliminat
- aria-label descriptiv pe fiecare buton icon
- data-dirty-form pe butonul de salvare (permite JS dirty-state)
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post(
"/login",
data={"email": email, "parola": password, "csrf_token": m.group(1)},
)
assert resp.status_code == 303
def _seed_saved_mapping(acct_id: int) -> None:
"""Insereaza o mapare salvata in operations_mapping."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (?, ?, ?, ?)",
(acct_id, "OP-TEST-77", "OE-1", 1),
)
conn.commit()
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "mapari_actiuni.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_butoane_icon_vizibile_pe_rand_salvate(client):
"""Butoanele de salvare/stergere in 'Mapari salvate' au clasa icon-btn (mereu vizibile)."""
acct = _create_account_user("actiuni_icon@test.com")
_seed_saved_mapping(acct)
_login(client, "actiuni_icon@test.com")
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
assert 'class="icon-btn' in html, (
"Butoanele de actiune din 'Mapari salvate' trebuie sa aiba clasa 'icon-btn' "
"(mereu vizibile pe rand, nu ascunse in kebab)."
)
def test_fara_kebab_meniu(client):
"""Meniul kebab (details.kebab / kebab-menu) e eliminat din 'Mapari salvate'."""
acct = _create_account_user("actiuni_kebab@test.com")
_seed_saved_mapping(acct)
_login(client, "actiuni_kebab@test.com")
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
assert 'class="kebab"' not in html, (
"Meniul kebab (details.kebab) trebuie eliminat din 'Mapari salvate' (US-011)."
)
assert '"kebab-menu"' not in html, (
"Clasa 'kebab-menu' trebuie eliminata complet din 'Mapari salvate' (US-011)."
)
def test_butoane_cu_aria_label(client):
"""Butoanele icon-btn au aria-label descriptiv."""
acct = _create_account_user("actiuni_aria@test.com")
_seed_saved_mapping(acct)
_login(client, "actiuni_aria@test.com")
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
icon_btns = re.findall(r'<button[^>]+class="icon-btn[^"]*"[^>]*>', html)
assert icon_btns, "Trebuie sa existe butoane cu clasa icon-btn in 'Mapari salvate'."
assert any('aria-label' in btn for btn in icon_btns), (
"Cel putin un buton icon-btn trebuie sa aiba atributul aria-label descriptiv."
)
def test_dirty_state_data_attr(client):
"""Butonul de salvare are data-dirty-form pentru dirty-state JS."""
acct = _create_account_user("actiuni_dirty@test.com")
_seed_saved_mapping(acct)
_login(client, "actiuni_dirty@test.com")
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
assert 'data-dirty-form=' in html, (
"Butonul de salvare trebuie sa aiba atributul data-dirty-form pentru JS dirty-state. "
"Cand utilizatorul schimba selectul, JS adauga clasa 'dirty' pe buton (fundal --accent)."
)

View File

@@ -0,0 +1,135 @@
"""Teste US-010 (PRD 5.10): restructurare pagina Mapari intr-o singura pagina consolidata.
Modificari cerute:
- Ordinea sectiunilor: (1) De rezolvat, (2) Mapari salvate, (3) Reguli automate, (4) Formate coloane.
- Sectiunea de ajutor (<details class="ajutor-mapari">) eliminata.
- Textul empty-state „Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR..." eliminat.
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post(
"/login",
data={"email": email, "parola": password, "csrf_token": m.group(1)},
)
assert resp.status_code == 303
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "mapari_layout.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_de_rezolvat_prima(client):
"""Sectiunea „De rezolvat" apare prima — inaintea „Mapari operatii salvate"."""
_create_account_user("mapari_ord@test.com")
_login(client, "mapari_ord@test.com")
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
pos_de_rezolvat = html.find("De rezolvat")
pos_salvate = html.find("Mapari operatii salvate")
assert pos_de_rezolvat != -1, "Sectiunea 'De rezolvat' trebuie sa existe in pagina."
assert pos_salvate != -1, "Sectiunea 'Mapari operatii salvate' trebuie sa existe in pagina."
assert pos_de_rezolvat < pos_salvate, (
f"'De rezolvat' (pozitia {pos_de_rezolvat}) trebuie sa apara INAINTE de "
f"'Mapari operatii salvate' (pozitia {pos_salvate})."
)
def test_fara_ajutor_si_empty_text(client):
"""Sectiunea de ajutor (ajutor-mapari) si empty-text-ul specific sunt eliminate."""
_create_account_user("mapari_fara@test.com")
_login(client, "mapari_fara@test.com")
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
# Sectiunea de ajutor eliminata
assert 'ajutor-mapari' not in html, (
"Clasa 'ajutor-mapari' (details de ajutor) trebuie eliminata din pagina Mapari (US-010)."
)
assert 'class="ajutor-mapari"' not in html, (
"<details class=\"ajutor-mapari\"> trebuie eliminat."
)
# Empty-text specific „Nicio operatie nemapata" eliminat
assert "Nicio operatie nemapata" not in html, (
"Textul empty-state 'Nicio operatie nemapata — tot ce a venit...' trebuie eliminat (US-010)."
)
assert "tot ce a venit s-a tradus in coduri RAR" not in html, (
"Textul empty-state extins trebuie eliminat complet."
)
def test_ordine_sectiuni(client):
"""Ordinea corecta a sectiunilor: De rezolvat → Mapari salvate → Reguli automate → Formate."""
_create_account_user("mapari_ord2@test.com")
_login(client, "mapari_ord2@test.com")
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
pos1 = html.find("De rezolvat")
pos2 = html.find("Mapari operatii salvate")
pos3 = html.find("Reguli automate")
pos4 = html.find("Formate de coloane")
assert pos1 != -1, "Sectiunea 'De rezolvat' trebuie sa existe."
assert pos2 != -1, "Sectiunea 'Mapari operatii salvate' trebuie sa existe."
assert pos3 != -1, "Sectiunea 'Reguli automate' trebuie sa existe."
assert pos4 != -1, "Sectiunea 'Formate de coloane' trebuie sa existe."
assert pos1 < pos2, "De rezolvat trebuie sa fie inaintea Mapari salvate."
assert pos2 < pos3, (
f"'Mapari operatii salvate' (poz {pos2}) trebuie sa fie inaintea "
f"'Reguli automate' (poz {pos3}). Acum Reguli automate e ultima sectiune — "
"muta-o pe pozitia 3 (inaintea Formate de coloane)."
)
assert pos3 < pos4, (
f"'Reguli automate' (poz {pos3}) trebuie sa fie inaintea "
f"'Formate de coloane' (poz {pos4})."
)

View File

@@ -0,0 +1,150 @@
"""Teste US-009 (PRD 5.10): Mapari in meniu hamburger + scoatere tab-uri.
TDD: testele se scriu INAINTE de implementare.
Acceptance criteria testate:
- test_meniu_contine_mapari: meniul #cont-menu are o intrare Mapari
(href=/?tab=mapari); badge vizibil cand exista needs_mapping.
- test_pagina_principala_fara_tabbar_mapari: pagina / nu mai are role="tablist"
(tab-bar-ul Acasa/Mapari a fost eliminat).
- test_ruta_mapari_randeaza_sectiunea: GET /?tab=mapari → 200, sectiunea mapari
randata (id="mapari-section"), fara role="tablist" rezidual.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, "Service Test Meniu", 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
def _ins_needs_mapping(acct: int) -> 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 (?, ?, 'needs_mapping', ?)",
(f"k-us009-{os.urandom(4).hex()}", acct, json.dumps({"prestatii": [{"cod_op_service": "X"}]})),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "meniu_test.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_meniu_contine_mapari(client):
"""Meniul hamburger (#cont-menu) contine o intrare cu href=/?tab=mapari."""
_create_account_user("menu1@test.com")
_login(client, "menu1@test.com")
html = client.get("/").text
# Intrarea Mapari trebuie sa existe in meniu
assert 'href="/?tab=mapari"' in html, (
"Meniul hamburger trebuie sa contina o intrare cu href='/?tab=mapari'"
)
# Textul "Mapari" trebuie sa apara in meniu (in apropierea link-ului)
idx = html.find('href="/?tab=mapari"')
assert idx != -1
# Cauta "Mapari" in fereastra contextului link-ului
window = html[max(0, idx - 50):idx + 100]
assert "Mapari" in window, (
f"Textul 'Mapari' trebuie sa apara langa href=/?tab=mapari: ...{window}..."
)
def test_meniu_badge_needs_mapping(client):
"""Badge vizibil in meniu cand exista submissions needs_mapping."""
acct = _create_account_user("menu2@test.com")
_ins_needs_mapping(acct)
_login(client, "menu2@test.com")
html = client.get("/").text
# Badgeul trebuie sa apara in apropierea intrarii Mapari
idx = html.find('href="/?tab=mapari"')
assert idx != -1, "Intrarea Mapari lipseste din meniu"
# Cauta tab-badge in contextul intrarii Mapari (in tag-ul/blocul imediat urmator)
window = html[idx:idx + 300]
assert "tab-badge" in window, (
"Badgeul (tab-badge) trebuie sa apara in intrarea Mapari cand exista needs_mapping"
)
def test_pagina_principala_fara_tabbar_mapari(client):
"""Pagina principala / nu mai are role=tablist (tab-bar-ul eliminat in US-009)."""
_create_account_user("menu3@test.com")
_login(client, "menu3@test.com")
html = client.get("/").text
assert 'role="tablist"' not in html, (
"Tab-bar-ul (role=tablist) trebuie eliminat din pagina principala (US-009)"
)
# Nici rolul de tab individual nu trebuie sa existe in tab-bar
# (role=tab poate exista in alte contexte, dar tab-bar-ul tablist+tab nu)
assert 'class="tab-bar"' not in html, (
"Clasa CSS tab-bar trebuie eliminata din pagina principala (US-009)"
)
def test_ruta_mapari_randeaza_sectiunea(client):
"""GET /?tab=mapari → 200, sectiunea mapari randata, fara tablist rezidual."""
_create_account_user("menu4@test.com")
_login(client, "menu4@test.com")
resp = client.get("/?tab=mapari")
assert resp.status_code == 200, (
f"/?tab=mapari trebuie sa returneze 200, nu {resp.status_code}"
)
html = resp.text
assert 'id="mapari-section"' in html, (
"Sectiunea mapari (id='mapari-section') trebuie randata la /?tab=mapari"
)
assert 'role="tablist"' not in html, (
"Tab-bar-ul (role=tablist) nu trebuie sa apara nici la /?tab=mapari"
)

View File

@@ -161,58 +161,48 @@ def test_modal_hookuri_js_prezente(client):
assert "window.inchideDetaliu" in js
# --- PRD 5.9 US-005 (R6): poll-guard ---------------------------------------
# Modalul + selectia trebuie sa supravietuiasca poll-ului de 15s. Logica e JS in
# base.html: testam la nivel de markup/handler ca guard-ul exista si distinge corect
# sursa trigger-ului (periodic vs trimiteriChanged/filtru). Comportamentul runtime
# efectiv (anularea propriu-zisa) e validat E2E (requiresBrowserCheck) — aici asertam
# codul/atributele care il implementeaza.
# --- Tabelul nu se reincarca singur: modalul + selectia sunt sigure ---------
# Tabelul (#submissions-wrap) nu mai are poll periodic; se reincarca DOAR la load,
# la actiunile utilizatorului (trimiteriChanged) sau la apasarea pe Reincarca (nudge).
# Asa, modalul deschis si bifele de bulk nu pot fi sterse de un timer.
def test_poll_pauzat_cat_modal_deschis(client):
"""Guard-ul de poll exista si, cat modalul de detaliu e deschis, anuleaza
reincarcarea periodica a listei (#submissions-wrap), nu pe restul."""
_create_account_user("poll1@test.com")
def test_tabel_fara_poll_periodic(client):
"""#submissions-wrap nu are trigger periodic (`every Ns`) — niciun timer nu poate
reseta modalul deschis sau selectia de bulk in timpul interactiunii."""
acct = _create_account_user("poll1@test.com")
_login(client, "poll1@test.com")
js = client.get("/?tab=acasa").text
_insert_submission(acct)
html = client.get("/?tab=acasa").text
# Guard scopat la poll-ul listei, declansat pe htmx:beforeRequest.
assert "htmx:beforeRequest" in js
assert "d.elt.id !== 'submissions-wrap'" in js, "guard-ul trebuie scopat la #submissions-wrap"
# Conditia (a): modal deschis -> pauza (preventDefault).
assert "modalDeschis" in js
assert "modal-detaliu" in js and "hidden" in js
assert "evt.preventDefault()" in js, "pauza scopata se face prin preventDefault"
assert 'id="submissions-wrap"' in html
wrap = html[html.find('id="submissions-wrap"'):]
wrap = wrap[:wrap.find(">") + 1]
assert "every" not in wrap, f"tabelul nu trebuie sa aiba poll periodic: {wrap}"
def test_poll_pauzat_cat_exista_bifa(client):
"""Conditia (b): macar un checkbox de bulk bifat -> poll-ul periodic e pus pe
pauza. Resume pe checkbox `change` prin delegare pe body (prinde si bifele
randate dupa swap)."""
_create_account_user("poll2@test.com")
def test_nudge_date_noi_in_loc_de_poll(client):
"""Reimprospatarea live se face prin nudge-ul 'Date noi' (poller usor de versiune)
care NU atinge tabelul; utilizatorul reincarca explicit cand vrea."""
acct = _create_account_user("poll2@test.com")
_login(client, "poll2@test.com")
js = client.get("/?tab=acasa").text
_insert_submission(acct)
html = client.get("/?tab=acasa").text
# Detecteaza bifa de bulk in interiorul #submissions-wrap.
assert "existaBifa" in js
assert 'input[name="submission_id"]:checked' in js
# Resume: delegare pe body pe evenimentul `change` al checkbox-ului de bulk.
assert "addEventListener('change'" in js
assert "t.name === 'submission_id'" in js
assert 'id="nudge-trimiteri"' in html, "bannerul nudge 'Date noi' trebuie sa existe"
assert "/_fragments/trimiteri-versiune" in html, "pollerul de versiune trebuie configurat"
assert "reincarcaTrimiteri" in html, "reincarcarea manuala (Reincarca) trebuie expusa"
def test_trimiteriChanged_inca_reincarca_cu_bifa(client):
"""R6 (F5): guard-ul NU anuleaza request-urile cu `triggeringEvent`
(trimiteriChanged / submit filtru) — acelea TREC MEREU, ca pauza sa nu ramana
lipita permanent daca randul bifat paraseste filtrul."""
_create_account_user("poll3@test.com")
def test_trimiteriChanged_inca_reincarca(client):
"""Actiunile utilizatorului (corectie / stergere) reincarca tabelul prin canalul
`trimiteriChanged`, pastrand filtrul curent (hx-include #filtre-trimiteri)."""
acct = _create_account_user("poll3@test.com")
_login(client, "poll3@test.com")
js = client.get("/?tab=acasa").text
_insert_submission(acct)
html = client.get("/?tab=acasa").text
# Numai trigger-ul periodic (fara triggeringEvent) e candidat la pauza;
# orice request cu triggeringEvent iese devreme din guard.
assert "triggeringEvent" in js
assert "rc.triggeringEvent) return" in js, \
"request-urile cu triggeringEvent (trimiteriChanged/filtru) trebuie sa treaca mereu"
# Resume explicit reutilizeaza acelasi canal `trimiteriChanged` (pastreaza filtrul).
assert "trimiteriChanged" in js
wrap = html[html.find('id="submissions-wrap"'):]
wrap = wrap[:wrap.find(">") + 1]
assert "trimiteriChanged from:body" in wrap, "tabelul trebuie sa reincarce pe trimiteriChanged"
assert 'hx-include="#filtre-trimiteri"' in wrap, "reincarcarea trebuie sa pastreze filtrul"

View File

@@ -0,0 +1,182 @@
"""Teste US-004 (PRD 5.10): paginare numerotata pe tabelul de trimiteri.
Cazuri:
- pagina_implicita_25: 30 trimiteri → pagina 1 afiseaza max 25
- pagina_2_offset: 30 trimiteri, page=2 → 5 randuri
- total_si_numar_pagini: raspunsul contine totalul + aria-current pe pagina curenta
- paginarea_pastreaza_filtrele: linkurile de paginare includ filtrul status activ
- pagina_peste_total_revine_la_ultima: page=99 cu 30 trimiteri → clamped la pagina 2
- poll_pastreaza_pagina: raspunsul include id='f-page' value='2' (OOB) pentru poll
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _ins_n(acct: int, n: int, status: str = "sent") -> None:
"""Insereaza n submissions pentru contul dat."""
from app.db import get_connection
conn = get_connection()
try:
for i in range(n):
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(
f"k-pg-{os.urandom(6).hex()}", acct, status,
json.dumps({
"vin": f"WVIN_PG_{i:04d}_DUMMY",
"nr_inmatriculare": f"B{i:03d}PG",
"data_prestatie": "2026-06-20",
"odometru_final": "100",
"prestatii": [{"cod_prestatie": "R-X"}],
}),
),
)
conn.commit()
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "paginare.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_pagina_implicita_25(client):
"""Cu 30 trimiteri, pagina 1 (implicita) returneaza exact 25 randuri."""
acct = _create_account_user("pg1@test.com")
_ins_n(acct, 30)
_login(client, "pg1@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
body = resp.text
row_count = body.count('id="trimitere-row-')
assert row_count == 25, (
f"Pagina 1 cu 30 trimiteri trebuie sa arate exact 25 randuri, nu {row_count}"
)
def test_pagina_2_offset(client):
"""Cu 30 trimiteri, page=2 returneaza restul de 5 randuri (offset 25)."""
acct = _create_account_user("pg2@test.com")
_ins_n(acct, 30)
_login(client, "pg2@test.com")
resp = client.get("/_fragments/submissions?page=2")
assert resp.status_code == 200
body = resp.text
row_count = body.count('id="trimitere-row-')
assert row_count == 5, (
f"Pagina 2 cu 30 total trebuie sa arate 5 randuri, nu {row_count}"
)
def test_total_si_numar_pagini(client):
"""Raspunsul contine totalul (30) si marcheaza pagina curenta cu aria-current='page'."""
acct = _create_account_user("pg3@test.com")
_ins_n(acct, 30)
_login(client, "pg3@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
body = resp.text
# Totalul trebuie afisat undeva (ex. "30 din 30" sau "afiseaza 1-25 din 30")
assert "30" in body, "Totalul 30 trebuie sa apara in raspuns"
# Pagina curenta e marcata semantic
assert 'aria-current="page"' in body, (
"Pagina curenta trebuie marcata cu aria-current='page'"
)
def test_paginarea_pastreaza_filtrele(client):
"""Linkurile de paginare pastreaza filtrul status activ in URL."""
acct = _create_account_user("pg4@test.com")
_ins_n(acct, 30, status="needs_data")
_login(client, "pg4@test.com")
resp = client.get("/_fragments/submissions?status=needs_data&page=1")
assert resp.status_code == 200
body = resp.text
# Pager-ul exista si linkurile contin status=needs_data
assert "status=needs_data" in body, (
"Linkurile de paginare trebuie sa pastreze filtrul status=needs_data"
)
def test_pagina_peste_total_revine_la_ultima(client):
"""page=99 cu 30 trimiteri se clampeaza la ultima pagina (page 2 → 5 randuri)."""
acct = _create_account_user("pg5@test.com")
_ins_n(acct, 30)
_login(client, "pg5@test.com")
resp = client.get("/_fragments/submissions?page=99")
assert resp.status_code == 200
body = resp.text
row_count = body.count('id="trimitere-row-')
assert row_count == 5, (
f"page=99 cu 30 trimiteri trebuie clamped la ultima pagina (5 randuri), nu {row_count}"
)
def test_poll_pastreaza_pagina(client):
"""Raspunsul de la page=2 include id='f-page' value='2' (OOB swap) pentru poll.
Mecanismul: _submissions.html include un element cu id='f-page' si hx-swap-oob='true'
care actualizeaza inputul ascuns din #filtre-trimiteri. Poll-ul de 15s (hx-include=
'#filtre-trimiteri') include astfel pagina curenta la urmatoarea iteratie (L2 PRD).
"""
acct = _create_account_user("pg6@test.com")
_ins_n(acct, 30)
_login(client, "pg6@test.com")
resp = client.get("/_fragments/submissions?page=2")
assert resp.status_code == 200
body = resp.text
# Elementul OOB trebuie sa fie in raspuns cu valoarea corecta
assert 'id="f-page"' in body, (
"Raspunsul trebuie sa includa id='f-page' (OOB swap) pentru ca poll-ul sa pastreze pagina"
)
assert 'value="2"' in body, (
"Elementul f-page trebuie sa aiba value='2' cand page=2"
)

View File

@@ -0,0 +1,171 @@
"""Teste US-003 (PRD 5.10): pill-uri de filtrare per categorie in bara de status.
Cazuri:
- pill_per_categorie_cu_numar: pill-uri <button> cu numarul corect per categorie
- pill_click_seteaza_status: pill-urile au atributele HTMX corecte (status=X, aria-pressed)
- fara_lista_id_uri: lista de ID-uri/VIN-uri nu mai apare in bara de status
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _ins(acct: int, *, status: str, vin: str = "WVIN000000000001", nr: str = "B001") -> int:
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(
f"k-{os.urandom(5).hex()}", acct, status,
json.dumps({"vin": vin, "nr_inmatriculare": nr, "data_prestatie": "2026-06-20",
"odometru_final": "100", "prestatii": [{"cod_prestatie": "R-X"}]}),
),
)
conn.commit()
return cur.lastrowid # type: ignore[return-value]
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "pill.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_pill_per_categorie_cu_numar(client):
"""Bara de status afiseaza pill-uri <button> cu numarul corect per categorie blocata."""
acct = _create_account_user("pill1@test.com")
# 2x needs_data, 1x needs_mapping, 1x error, 1x sent (fara pill)
_ins(acct, status="needs_data", vin="WVIN_ND1_001", nr="BND1")
_ins(acct, status="needs_data", vin="WVIN_ND1_002", nr="BND2")
_ins(acct, status="needs_mapping", vin="WVIN_NM1_001", nr="BNM1")
_ins(acct, status="error", vin="WVIN_ER1_001", nr="BER1")
_ins(acct, status="sent", vin="WVIN_SE1_001", nr="BSE1")
_login(client, "pill1@test.com")
# Pill-urile traiesc in bara de filtre din sectiunea Trimiteri.
resp = client.get("/?tab=acasa")
assert resp.status_code == 200
body = resp.text
# Pill-urile sunt elemente <button>
assert "<button" in body, "Pill-urile trebuie sa fie elemente <button>"
# Fiecare categorie problemativa apare ca pill
assert 'data-status="needs_data"' in body, "Pill needs_data trebuie sa apara"
assert 'data-status="needs_mapping"' in body, "Pill needs_mapping trebuie sa apara"
assert 'data-status="error"' in body, "Pill error trebuie sa apara"
# Contoarele sunt afisate in pill-uri
assert ">2<" in body or "2<" in body, "Contorul 2 pt needs_data trebuie vizibil in pill"
# Starea 'sent' nu produce pill (nu e categorie de problema)
assert 'data-status="sent"' not in body, "Nu trebuie pill pentru sent"
def test_pill_click_seteaza_status(client):
"""Pill-urile au atributele HTMX corecte: hx-get cu status=X si aria-pressed."""
acct = _create_account_user("pill2@test.com")
_ins(acct, status="needs_data", vin="WVIN_ND2_001", nr="BND_P2a")
_ins(acct, status="needs_mapping", vin="WVIN_NM2_001", nr="BNM_P2a")
_ins(acct, status="error", vin="WVIN_ER2_001", nr="BER_P2a")
_login(client, "pill2@test.com")
resp = client.get("/?tab=acasa")
assert resp.status_code == 200
body = resp.text
# Fiecare pill scrie campul de filtru prin filtreazaStare(this, 'X')
assert "filtreazaStare(this, 'needs_data')" in body, "Pill needs_data trebuie sa apeleze filtreazaStare cu needs_data"
assert "filtreazaStare(this, 'needs_mapping')" in body, "Pill needs_mapping trebuie sa apeleze filtreazaStare cu needs_mapping"
assert "filtreazaStare(this, 'error')" in body, "Pill error trebuie sa apeleze filtreazaStare cu error"
# Pill-urile au aria-pressed pentru accesibilitate (WCAG)
assert "aria-pressed" in body, "Pill-urile trebuie sa aiba atribut aria-pressed"
# Filtrarea trece prin form-ul care targeteaza tabelul de trimiteri
assert "submissions-wrap" in body and "_fragments/submissions" in body, (
"Form-ul de filtre trebuie sa targeteze #submissions-wrap prin /_fragments/submissions"
)
def test_fara_lista_id_uri(client):
"""Lista de ID-uri/VIN-uri (ex. #1 WVIN... / B...) nu mai apare in bara de status."""
acct = _create_account_user("pill3@test.com")
sid = _ins(acct, status="needs_data", vin="WVIN_ND3_UNIC001", nr="BND_P3")
_login(client, "pill3@test.com")
resp = client.get("/_fragments/status")
assert resp.status_code == 200
body = resp.text
# Structura <li class="muted"> cu randuri blocate a disparut
assert '<li class="muted"' not in body, (
"Structura <li class='muted'> cu randuri blocate trebuie eliminata din bara de status"
)
# VIN-ul complet nu mai apare in bara de status (era partial, dar nici partial nu mai vrem)
assert "WVIN_ND3_UNIC001" not in body, (
"VIN-ul (sau partial) nu trebuie sa mai apara in bara de status"
)
def test_pill_needs_mapping_culoare_warn(client):
"""Pill-ul 'Lipsa cod' (needs_mapping) foloseste --warn (chihlimbar), nu --err (DESIGN.md).
DESIGN.md §Componente: 'Lipsa cod = --warn'. Celelalte categorii (needs_data, error) = --err.
"""
acct = _create_account_user("warn@test.com")
# Inseram DOAR needs_mapping — pentru a izola culoarea si a nu confunda cu --err
_ins(acct, status="needs_mapping", vin="WVIN_NM_WARN0001", nr="BNMW1")
_login(client, "warn@test.com")
resp = client.get("/?tab=acasa")
assert resp.status_code == 200
body = resp.text
# Pill-ul needs_mapping trebuie sa aiba culoarea --warn (nu --err)
assert "var(--warn)" in body, (
"Pill needs_mapping trebuie sa foloseasca var(--warn) conform DESIGN.md §Componente"
)
assert 'data-status="needs_mapping"' in body, "Pill needs_mapping trebuie sa fie prezent in bara de filtre"

View File

@@ -104,8 +104,6 @@ def test_modal_fullscreen_clasa_mobil(client):
# Exista un bloc media mobil care vizeaza modalul.
assert "@media (max-width:767px)" in html
# Markerul US-006 pentru modalul full-screen pe mobil.
assert "US-006" in html
# Dialogul ocupa tot ecranul pe mobil (latime/inaltime pline, fara border-radius lateral).
mobil = html[html.find("@media (max-width:767px)"):]
assert "100vw" in mobil or "width:100%" in mobil
@@ -184,7 +182,6 @@ def test_tabele_continut_au_clasa_responsive(client):
assert 'data-eticheta="Daca operatia contine"' in mapari
# Regula de card e definita o data in base.html, scopata pe `.tabel-card`.
assert ".tabel-card thead" in mapari
assert "US-007" in mapari
# --- Jurnal = scroll contained. Cu un eveniment seedat, tabelul apare in `.tablewrap`,
# NU ca `.tabel-card`. ---
@@ -259,7 +256,6 @@ def test_acasa_fara_scroll_orizontal_mobil(client):
assert 'id="import-section"' in html
assert 'id="status-bar"' in html
assert 'id="filtre-trimiteri"' in html
assert "US-008" in html
mobil = html[html.find("@media (max-width:767px)"):]

View File

@@ -0,0 +1,179 @@
"""Teste US-014 (PRD 5.10): Selector de tema ciclic Light/Dark/Petrol/Auto.
TDD: testele se scriu INAINTE de implementare (RED), dupa implementare trec (GREEN).
Testeaza:
- test_petrol_theme_definit: [data-theme="petrol"] definit cu valorile din DESIGN.md
- test_buton_cicleaza_temele: buton ciclic + anti-FOUC extins + aria-live
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "selector.db"))
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 _get_style(html: str) -> str:
m = re.search(r"<style>(.*?)</style>", html, re.DOTALL)
assert m, "<style> negasit in HTML"
return m.group(1)
def _get_head(html: str) -> str:
m = re.search(r"<head>(.*?)</head>", html, re.DOTALL | re.IGNORECASE)
assert m, "<head> negasit in HTML"
return m.group(1)
# ── test_petrol_theme_definit ─────────────────────────────────────────────────
def test_petrol_theme_definit(client):
"""[data-theme="petrol"] definit in CSS cu valorile din DESIGN.md:
--bg:#0e1416, --card:#161e20, --ink:#e6e9ef, --muted:#8b93a7,
--line:#232c2e, --accent:#0E7C7B, --ok:#2FBF8F, --warn:#E0A93B, --err:#E05D5D
"""
resp = client.get("/login")
assert resp.status_code == 200
style = _get_style(resp.text)
# Bloc CSS [data-theme="petrol"] prezent
petrol_m = re.search(
r'\[data-theme=["\']petrol["\']\]\s*\{([^}]+)\}',
style,
re.DOTALL,
)
assert petrol_m, (
'[data-theme="petrol"] { ... } negasit in <style>. '
"Tema Petrol trebuie definita conform DESIGN.md."
)
petrol_block = petrol_m.group(1)
petrol_vars = {
"--accent": "#0E7C7B",
"--bg": "#0e1416",
"--card": "#161e20",
"--ink": "#e6e9ef",
"--line": "#232c2e",
"--ok": "#2FBF8F",
"--warn": "#E0A93B",
"--err": "#E05D5D",
}
for var, val in petrol_vars.items():
assert val.lower() in petrol_block.lower(), (
f"Variabila {var}:{val} lipseste din [data-theme=\"petrol\"]. "
f"Block petrol: {petrol_block.strip()}"
)
# ── test_buton_cicleaza_temele ────────────────────────────────────────────────
def test_buton_cicleaza_temele(client):
"""Butonul de tema cicleaza Light->Dark->Petrol->Auto si are accesibilitate completa.
Verifica:
1. Anti-FOUC extins: cunoaste 'petrol' si 'auto'; fallback definit pt. valori legacy
2. JS-ul ciclului contine toate cele 4 teme in ordinea corecta
3. aria-label pe buton include 'Tema:' + tema curenta + urmatoarea
4. Regiune aria-live="polite" prezenta pentru anuntarea schimbarii
"""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
head = _get_head(html)
# 1. Anti-FOUC cunoaste 'petrol' si 'auto' (script in <head>, inainte de <style>)
style_pos = head.find('<style>')
assert style_pos >= 0, "<style> negasit in <head>"
head_before_style = head[:style_pos]
assert 'petrol' in head_before_style, (
"Scriptul anti-FOUC nu cunoaste tema 'petrol'. "
"Trebuie extins sa enumere toate cele 4 teme (light/dark/petrol/auto)."
)
assert 'auto' in head_before_style, (
"Scriptul anti-FOUC nu cunoaste tema 'auto'. "
"Trebuie sa rezolve 'auto' la light/dark inainte de primul paint."
)
# 2. Anti-FOUC are fallback pentru valori legacy/necunoscute (un set de valide)
# Acceptam: un obiect/array VALID, sau un if care verifica valorile cunoscute
has_valid_guard = (
'VALID' in head_before_style
or re.search(r'light.*dark.*petrol.*auto', head_before_style, re.DOTALL)
or 'indexOf' in head_before_style
)
assert has_valid_guard, (
"Anti-FOUC lipseste de un guard pentru valori legacy/necunoscute. "
"O valoare 'localStorage.theme' necunoscuta trebuie sa cada pe 'auto' sau 'dark'."
)
# 3. JS-ul din <body> contine ciclul complet Light->Dark->Petrol->Auto
# Cautam in tot HTML-ul (nu doar head) prezenta tuturor celor 4 teme in JS
# Acceptam: array explicit ['light','dark','petrol','auto'] sau logic echivalent
cycle_match = re.search(
r"['\"]light['\"].*['\"]dark['\"].*['\"]petrol['\"].*['\"]auto['\"]",
html,
re.DOTALL,
)
assert cycle_match, (
"Ciclul Light->Dark->Petrol->Auto negasit in JS. "
"Asteptat: array sau secventa cu toate cele 4 teme in ordine."
)
# 4. aria-label pe butonul tema-toggle include 'Tema:' (format 'Tema: X, apasa pentru Y')
tema_btn = re.search(
r'<button[^>]+id=["\']tema-toggle["\'][^>]*>',
html,
re.IGNORECASE,
)
assert tema_btn, "Butonul #tema-toggle negasit in HTML"
btn_tag = tema_btn.group(0)
# Acceptam aria-label setat initial in HTML SAU prin JS (aria-label din tag poate fi
# placeholder; testam ca JS-ul contine formatul corect)
has_tema_label = (
'Tema:' in html
or 'tema:' in html.lower()
or re.search(r'aria-label[^>]*[Tt]ema', html)
)
assert has_tema_label, (
"aria-label cu formatul 'Tema: ...' negasit in HTML/JS. "
"Butonul trebuie sa anunte tema curenta + urmatoarea."
)
# 5. Regiune aria-live="polite" prezenta (pentru anuntarea schimbarii de tema)
assert 'aria-live="polite"' in html or "aria-live='polite'" in html, (
'Regiune aria-live="polite" negasita in HTML. '
"Necesara pentru a anunta schimbarea temei catre screen-readers."
)
# 6. US-014b (decizie user): title-ul vizual al butonului = DOAR numele temei, NU ciclul.
# Verificam in sursa JS ca variabila TOOLTIP_CICLU (sirul cu "Ciclu: Light → Dark → ...") nu
# mai e folosita in title. aria-label-ul informativ (curenta+urmatoarea) RAMANE neschimbat.
assert 'TOOLTIP_CICLU' not in html, (
"Variabila TOOLTIP_CICLU inca prezenta in JS — title-ul trebuie simplificat. "
"US-014b: btn.title trebuie sa fie DOAR LABELS[s] (ex. 'Light'), "
"nu 'Tema: X. Ciclu: Light -> Dark -> Petrol -> Auto'. "
"aria-label-ul informativ RAMANE neschimbat."
)
# Verifica ca JS-ul seteaza btn.title la doar LABELS[s] (fara prefix 'Tema:' sau ciclul)
assert re.search(r"btn\.title\s*=\s*LABELS\[", html), (
"btn.title nu e setat la LABELS[s] in JS. "
"US-014b: formatul asteptat: btn.title = LABELS[s] (ex. rezultat: 'Petrol'). "
"Aria-label-ul informativ ramane separat: 'Tema: X, apasa pentru Y'."
)

View File

@@ -133,28 +133,17 @@ def test_status_blocate_defalcare(client):
_insert_submission("needs_data", acct_id)
_insert_submission("error", acct_id)
resp = client.get("/_fragments/status")
# Pill-urile s-au mutat in bara de filtre din sectiunea Trimiteri (nu in bara de status).
resp = client.get("/?tab=acasa")
assert resp.status_code == 200
html = resp.text
# Trebuie sa arate titlul grupului de blocate
assert "Necesita atentia ta" in html, (
f"Fragmentul nu contine 'Necesita atentia ta'. HTML: {html[:800]}"
)
# Trebuie sa arate etichetele umane pe motiv (din STARI_SUBMISSION in labels.py)
assert "Lipseste codul prestatiei" in html, (
"Fragmentul nu arata eticheta pentru needs_mapping"
)
assert "Date incomplete" in html, (
"Fragmentul nu arata eticheta pentru needs_data"
)
assert "Eroare la trimitere" in html, (
"Fragmentul nu arata eticheta pentru error"
)
# Trebuie sa arate numere concrete (2 needs_mapping, 1 needs_data, 1 error)
# Verificam ca exista cel putin un numar > 0 langa fiecare eticheta
# (nu strict format, ci prezenta datelor)
assert "2" in html or "1" in html, "Fragmentul nu arata numarul de submissions blocate"
# Pill-urile au etichetele scurte per categorie (nu etichetele lungi din eticheta_stare)
assert "Lipsa cod" in html, "Acasa nu arata pill-ul pentru needs_mapping"
assert "Date incomplete" in html, "Acasa nu arata pill-ul pentru needs_data"
assert "Eroare" in html, "Acasa nu arata pill-ul pentru error"
# Pill-urile arata numarul total per categorie (2 needs_mapping, 1 needs_data, 1 error)
assert 'class="pill-cat"' in html, "Pill-urile trebuie sa fie elemente cu clasa pill-cat"
assert "<button" in html, "Pill-urile trebuie sa fie elemente <button>"
# ============================================================
@@ -208,26 +197,30 @@ def _insert_submission_vehicul(status, account_id, vin, nr):
def test_categorie_blocata_linkeaza_la_trimiteri_filtrate(client):
"""Pill-ul error filtreaza tabelul prin filtreazaStare(this, 'error') in bara de filtre."""
acct_id, _ = _create_account_user("link@test.com", "parolasecreta10")
_login(client, "link@test.com", "parolasecreta10")
_insert_submission("error", acct_id)
html = client.get("/_fragments/status").text
# Link HTMX catre lista filtrata pe error + deep-link server-side
assert "/_fragments/submissions?status=error" in html
assert "tab=acasa&status=error" in html
html = client.get("/?tab=acasa").text
# Pill-ul scrie campul de filtru si re-trimite form-ul (nu mai navigheaza prin deep-link)
assert "filtreazaStare(this, 'error')" in html
assert 'data-status="error"' in html
assert "tab=acasa&status=error" not in html
def test_status_arata_identificator_rand_blocat(client):
def test_status_nu_arata_identificator_rand_blocat(client):
"""US-003 (PRD 5.10): VIN/nr inmatriculare nu mai apar in bara de status.
Lista de ID-uri a fost inlocuita cu pill-uri cu numar total (fara PII nominal)."""
acct_id, _ = _create_account_user("ident@test.com", "parolasecreta10")
_login(client, "ident@test.com", "parolasecreta10")
_insert_submission_vehicul("error", acct_id, "WVWZZZ1KZAW000123", "B123ABC")
html = client.get("/_fragments/status").text
# VIN partial (ultimele 4) + nr inmatriculare + #id
assert "0123" in html, "lipseste VIN partial"
assert "B123ABC" in html, "lipseste nr inmatriculare"
# Bara de status arata doar contoare, nu lista cu VIN/nr per rand (fara PII nominal)
assert "B123ABC" not in html, "Nr inmatriculare nu trebuie sa mai apara in bara de status"
assert "WVWZZZ1KZAW000123" not in html, "VIN integral nu trebuie expus"
assert "0123" not in html, "VIN partial nu trebuie sa mai apara in bara de status"
def test_scoped_pe_cont(client):

View File

@@ -0,0 +1,151 @@
"""Teste US-005 (PRD 5.10): VIN pe rand separat sub numarul de inmatriculare.
VIN-ul era randat ca <span> inline in aceeasi celula cu nr. Story-ul cere un
element block-level (div/small/p cu display:block) sub nr, in stil muted.
Testul asserteaza tipul elementului (block), nu doar prezenta textului.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post(
"/login",
data={"email": email, "parola": password, "csrf_token": m.group(1)},
)
assert resp.status_code == 303
def _ins(acct: int, *, vin: str = "", nr: str = "B01TST", status: str = "queued") -> int:
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(
f"k-{os.urandom(5).hex()}", acct, status,
json.dumps({
"vin": vin,
"nr_inmatriculare": nr,
"data_prestatie": "2026-06-20",
"odometru_final": "100",
"prestatii": [{"cod_prestatie": "R-X"}],
}),
),
)
conn.commit()
return cur.lastrowid # type: ignore[return-value]
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "layout_test.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_vin_pe_rand_separat_sub_nr(client):
"""VIN-ul apare intr-un element block-level (div/p/small cu display:block) sub nr.
Inainte: <span class="muted">...VIN...</span> inline.
Dupa: <div class="muted">...VIN...</div> (block, rand separat).
Testul asserteaza prezenta unui element block, nu doar textul.
"""
acct = _create_account_user("vin_layout@test.com")
sid = _ins(acct, vin="WVWZZZ1JZXW000001", nr="B123XYZ")
_login(client, "vin_layout@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
# VIN trunchiat trebuie sa apara in HTML
assert "000001" in html, "VIN-ul trunchiat trebuie sa apara in tabel"
# Elementul ce contine VIN-ul trebuie sa fie block-level (div, p, small etc.)
# NU un simplu <span> inline.
# Pattern: <div ... >...000001...</div> sau <p ... >...000001...</p>
# Acceptam orice block-level tag (div/p/small) care contine fragmentul VIN.
block_tags = ["div", "p", "small"]
vin_fragment = "000001"
found_block = any(
re.search(
rf"<{tag}[^>]*>[^<]*{re.escape(vin_fragment)}[^<]*</{tag}>",
html,
)
for tag in block_tags
)
assert found_block, (
f"VIN '{vin_fragment}' trebuie sa fie intr-un element block-level "
f"(div/p/small), nu intr-un <span> inline. HTML gasit: "
+ html[max(0, html.find(vin_fragment) - 80):html.find(vin_fragment) + 80]
)
# Elementul block trebuie sa aiba clasa 'muted' (stil discret)
muted_block = any(
re.search(
rf'<{tag}[^>]*class="[^"]*muted[^"]*"[^>]*>[^<]*{re.escape(vin_fragment)}[^<]*</{tag}>',
html,
)
for tag in block_tags
)
assert muted_block, (
f"Elementul block cu VIN trebuie sa aiba clasa 'muted'"
)
def test_vin_lipsa_nu_genereaza_rand_gol(client):
"""Cand VIN-ul lipseste (sau e EMPTY=''), nu apare un element gol in celula Vehicul."""
acct = _create_account_user("vin_gol@test.com")
sid = _ins(acct, vin="", nr="B999TST") # VIN gol -> EMPTY="—"
_login(client, "vin_gol@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
# Randul trebuie sa existe
assert f'id="trimitere-row-{sid}"' in html
# In coloana vehicul nu trebuie sa apara un element block gol cu "—"
# (garda != '—' exista deja, verifica ca e respectata)
assert 'class="muted"' not in html.split('col-vehicul')[1].split('col-operatie')[0] or \
'' not in (html.split('col-vehicul')[1].split('col-operatie')[0]), \
"Elementul muted din coloana Vehicul nu trebuie sa contina '' (rand gol VIN)"

View File

@@ -73,7 +73,7 @@ def client(monkeypatch):
# ============================================================
def test_dashboard_are_tabbar(client):
"""US-007 (5.5): tab-bar redus la Acasa + Mapari; Cont/Integrare/Nomenclator in meniul ☰."""
"""US-009 (5.10): tab-bar-ul eliminat; Mapari mutat in meniul ☰; rutele raman valide."""
_create_account_user("tabbar@test.com", "parolasecreta10")
_login(client, "tabbar@test.com", "parolasecreta10")
@@ -81,16 +81,15 @@ def test_dashboard_are_tabbar(client):
assert resp.status_code == 200
html = resp.text
assert 'role="tablist"' in html, "Lipseste role=tablist"
# Doar Acasa + Mapari sunt tab-uri (role="tab")
assert re.search(r'role="tab"[^>]*>\s*Acasa', html), "Lipseste tab-ul Acasa"
assert re.search(r'role="tab"[^>]*>\s*Mapari', html), "Lipseste tab-ul Mapari"
# Cont/Integrare/Nomenclator NU mai sunt tab-uri
# US-009: tab-bar-ul (role="tablist") a fost eliminat
assert 'role="tablist"' not in html, "Tab-bar-ul (role=tablist) trebuie eliminat (US-009)"
# Cont/Integrare/Nomenclator raman in meniu, nu ca tab-uri
for label in ("Cont", "Integrare", "Nomenclator", "Import"):
assert not re.search(rf'role="tab"[^>]*>\s*{label}\s*<', html), \
f"'{label}' nu ar mai trebui sa fie un tab separat (mutat in meniu)"
# ...dar traiesc in meniul de cont
f"'{label}' nu ar mai trebui sa fie un tab separat"
# Mapari e acum in meniu (nu tab), cu link valid
assert 'href="/?tab=mapari"' in html, "Lipseste link Mapari din meniu"
# Cont/Nomenclator raman in meniu
assert 'href="/?tab=cont"' in html and 'href="/?tab=nomenclator"' in html
@@ -99,7 +98,7 @@ def test_dashboard_are_tabbar(client):
# ============================================================
def test_tab_implicit_acasa(client):
"""Fara ?tab=, tab-ul Acasa are aria-selected=true."""
"""US-009: fara ?tab=, pagina principala randeaza continutul Acasa (upload + sectiuni)."""
_create_account_user("implicit@test.com", "parolasecreta10")
_login(client, "implicit@test.com", "parolasecreta10")
@@ -107,13 +106,11 @@ def test_tab_implicit_acasa(client):
assert resp.status_code == 200
html = resp.text
# Tab-ul activ trebuie sa aiba aria-selected="true"
assert 'aria-selected="true"' in html, "Lipseste aria-selected=true pe tab-ul activ"
# Verificam ca Acasa e cel cu aria-selected=true
# Cautam un fragment care contine atat Acasa cat si aria-selected="true" in proximitate
assert re.search(r'aria-selected="true"[^>]*>.*?Acasa|Acasa.*?aria-selected="true"', html, re.DOTALL), \
"Tab-ul Acasa nu are aria-selected=true"
# US-009: tab-bar eliminat, deci nu mai exista aria-selected pe tab-uri
assert 'role="tablist"' not in html, "Tab-bar-ul trebuie eliminat (US-009)"
# Continutul Acasa (status-bar + tab-panel cu continut Acasa) e randat direct
assert 'id="status-bar"' in html, "Status-bar-ul trebuie sa fie prezent"
assert 'id="tab-panel"' in html, "Panoul de continut (tab-panel) trebuie sa fie prezent"
# ============================================================
@@ -140,23 +137,22 @@ def test_deeplink_tab_import(client):
# ============================================================
def test_tab_activ_randat_server_side(client):
"""Panoul activ e in HTML-ul initial, nu doar cerut prin HTMX dupa load."""
"""Panoul activ e in HTML-ul initial, randat server-side (nu doar HTMX dupa load)."""
_create_account_user("serverside@test.com", "parolasecreta10")
_login(client, "serverside@test.com", "parolasecreta10")
# Tab-ul implicit (Acasa) trebuie sa fie randat server-side
# Acasa e randat server-side
resp = client.get("/")
assert resp.status_code == 200
html = resp.text
# Panoul trebuie sa aiba role="tabpanel"
assert 'role="tabpanel"' in html, "Lipseste role=tabpanel in HTML initial"
# US-009: role="tabpanel" eliminat; continutul e in div#tab-panel fara rol ARIA de tabpanel
assert 'id="tab-panel"' in html, "Containerul de continut tab-panel trebuie sa existe"
assert 'role="tabpanel"' not in html, "role=tabpanel trebuie eliminat (US-009)"
# Import tab server-side
# Import tab server-side: ?tab=import randeaza direct continutul Import
resp2 = client.get("/?tab=import")
assert resp2.status_code == 200
html2 = resp2.text
# Continutul Import trebuie sa fie randat direct, nu prin hx-trigger=load pe panoul inactiv
assert 'id="import-section"' in html2, "Panoul Import nu e randat server-side la ?tab=import"
@@ -205,7 +201,7 @@ def test_fragmentele_inactive_lazy(client):
# ============================================================
def test_tabbar_aria(client):
"""Prezenta atributelor ARIA: role=tablist/tab/tabpanel, aria-selected."""
"""US-009: schela ARIA orfana (role=tablist/tab/tabpanel/aria-selected) a fost eliminata."""
_create_account_user("aria@test.com", "parolasecreta10")
_login(client, "aria@test.com", "parolasecreta10")
@@ -213,11 +209,14 @@ def test_tabbar_aria(client):
assert resp.status_code == 200
html = resp.text
assert 'role="tablist"' in html, "Lipseste role=tablist"
assert 'role="tab"' in html, "Lipseste role=tab"
assert 'role="tabpanel"' in html, "Lipseste role=tabpanel"
assert 'aria-selected="true"' in html, "Lipseste aria-selected=true pe tab-ul activ"
assert 'aria-selected="false"' in html, "Lipseste aria-selected=false pe tab-urile inactive"
# US-009: un role="tablist" cu un singur tab e violare ARIA → eliminat
assert 'role="tablist"' not in html, "role=tablist trebuie eliminat (US-009)"
assert 'role="tab"' not in html, "role=tab trebuie eliminat (tab-bar eliminat)"
assert 'role="tabpanel"' not in html, "role=tabpanel trebuie eliminat (tab-bar eliminat)"
assert 'aria-selected=' not in html, "aria-selected trebuie eliminat (fara tab-uri)"
# Meniu cont (role="menu") si item-urile sale (role="menuitem") raman valide
assert 'role="menu"' in html, "Meniul hamburger (role=menu) trebuie pastrat"
assert 'role="menuitem"' in html, "Intrarile meniului (role=menuitem) trebuie pastrate"
# ============================================================

View File

@@ -0,0 +1,256 @@
"""Teste US-013 (PRD 5.10): Tema de culori ROMFAST (accent azur) + tipografie IBM Plex.
TDD: testele se scriu INAINTE de implementare (RED), dupa implementare trec (GREEN).
Testeaza:
- test_paleta_accent_azur_definita: accentul azur ROMFAST definit corect in :root si [data-theme="light"]
- test_font_ibm_plex_aplicat: IBM Plex Sans + Mono declarate in font-family si @font-face
- test_contrast_aa_pe_text_principal: contrast text principal >= 4.5:1 in dark si light
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "culori.db"))
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 _get_style_block(html: str) -> str:
"""Extrage continutul primului <style> din HTML."""
m = re.search(r"<style>(.*?)</style>", html, re.DOTALL)
assert m, "<style> negasit in HTML"
return m.group(1)
def _hex_to_srgb(hex_color: str) -> tuple[float, float, float]:
"""Converteste hex (#rrggbb) la tuple (r, g, b) in [0,1]."""
h = hex_color.lstrip("#")
assert len(h) == 6, f"Hex invalid: {hex_color}"
r = int(h[0:2], 16) / 255.0
g = int(h[2:4], 16) / 255.0
b = int(h[4:6], 16) / 255.0
return r, g, b
def _linearize(c: float) -> float:
"""Liniarizeaza o componenta sRGB pentru calcul luminanta WCAG."""
if c <= 0.04045:
return c / 12.92
return ((c + 0.055) / 1.055) ** 2.4
def _luminance(hex_color: str) -> float:
"""Calculeaza luminanta relativa WCAG 2.1 pentru o culoare hex."""
r, g, b = _hex_to_srgb(hex_color)
rl = _linearize(r)
gl = _linearize(g)
bl = _linearize(b)
return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl
def _contrast_ratio(c1: str, c2: str) -> float:
"""Calculeaza raportul de contrast WCAG 2.1 intre doua culori hex."""
l1 = _luminance(c1)
l2 = _luminance(c2)
lighter = max(l1, l2)
darker = min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
# ── test_paleta_accent_azur_definita ─────────────────────────────────────────
def test_paleta_accent_azur_definita(client):
"""Accentul azur ROMFAST definit corect si neutrele actualizate conform DESIGN.md.
:root (dark default):
--accent:#2E74D6
--bg:#0f1218 --card:#181c24 --ink:#e6e9ef --muted:#8b93a7 --line:#262b36
--ok:#2FBF8F --warn:#E0A93B --err:#E05D5D
[data-theme="light"]:
--accent:#1F66C9
--bg:#f5f7fa --card:#ffffff --ink:#1a1d24 --muted:#5c6473 --line:#e2e5ea
--ok:#15803d --warn:#b45309 --err:#dc2626
"""
resp = client.get("/login")
assert resp.status_code == 200
style = _get_style_block(resp.text)
# Paleta dark (:root)
dark_vars = {
"--accent": "#2E74D6",
"--bg": "#0f1218",
"--card": "#181c24",
"--ink": "#e6e9ef",
"--muted": "#8b93a7",
"--line": "#262b36",
"--ok": "#2FBF8F",
"--warn": "#E0A93B",
"--err": "#E05D5D",
}
# Extrage blocul :root
root_m = re.search(r":root\s*\{([^}]+)\}", style, re.DOTALL)
assert root_m, "Blocul :root negasit in <style>"
root_block = root_m.group(1)
for var, val in dark_vars.items():
assert val.lower() in root_block.lower(), (
f"Variabila {var}:{val} lipseste sau are valoare gresita in :root (dark). "
f"Continut :root: {root_block.strip()}"
)
# Paleta light ([data-theme="light"])
light_vars = {
"--accent": "#1F66C9",
"--bg": "#f5f7fa",
"--card": "#ffffff",
"--ink": "#1a1d24",
"--muted": "#5c6473",
"--line": "#e2e5ea",
"--ok": "#15803d",
"--warn": "#b45309",
"--err": "#dc2626",
}
light_m = re.search(r'\[data-theme=["\']light["\']\]\s*\{([^}]+)\}', style, re.DOTALL)
assert light_m, 'Blocul [data-theme="light"] negasit in <style>'
light_block = light_m.group(1)
for var, val in light_vars.items():
assert val.lower() in light_block.lower(), (
f"Variabila {var}:{val} lipseste sau are valoare gresita in [data-theme=\"light\"]. "
f"Continut light: {light_block.strip()}"
)
# ── test_font_ibm_plex_aplicat ────────────────────────────────────────────────
def test_font_ibm_plex_aplicat(client):
"""IBM Plex Sans si IBM Plex Mono declarate in font-family si @font-face cu font-display:swap.
Verifica:
- body font-family contine 'IBM Plex Sans' (sau alias ibm-plex-sans)
- exista cel putin un @font-face cu 'IBM Plex Sans' sau 'IBM Plex Mono'
- @font-face include font-display:swap
- @font-face pointeaza spre /static/fonts/
"""
resp = client.get("/login")
assert resp.status_code == 200
style = _get_style_block(resp.text)
# 1. body font-family contine IBM Plex Sans
body_m = re.search(r"body\s*\{([^}]+)\}", style, re.DOTALL)
assert body_m, "Regula 'body { ... }' negasita in <style>"
body_block = body_m.group(1)
assert "IBM Plex Sans" in body_block or "ibm-plex-sans" in body_block.lower(), (
f"'IBM Plex Sans' lipseste din font-family al body. body block: {body_block.strip()}"
)
# 2. Exista cel putin un @font-face cu IBM Plex
font_face_blocks = re.findall(r"@font-face\s*\{([^}]+)\}", style, re.DOTALL)
assert font_face_blocks, "@font-face negasit in <style>"
ibm_face = [b for b in font_face_blocks if "IBM Plex" in b or "ibm-plex" in b.lower()]
assert ibm_face, (
"@font-face cu 'IBM Plex Sans' sau 'IBM Plex Mono' negasit. "
f"Blocuri @font-face gasite: {font_face_blocks}"
)
# 3. font-display:swap prezent in cel putin un bloc IBM Plex @font-face
swap_present = any("swap" in b.lower() for b in ibm_face)
assert swap_present, (
"font-display:swap lipseste din @font-face IBM Plex. "
f"Blocuri @font-face IBM Plex: {ibm_face}"
)
# 4. @font-face pointeaza spre /static/fonts/
fonts_src = any("/static/fonts/" in b for b in ibm_face)
assert fonts_src, (
"@font-face IBM Plex nu pointeaza spre /static/fonts/. "
f"Blocuri: {ibm_face}"
)
# 5. IBM Plex Mono pentru monospace: exista un context monospace cu IBM Plex Mono
# (fie @font-face, fie o regula font-family cu monospace)
has_mono = any("IBM Plex Mono" in b or "ibm-plex-mono" in b.lower() for b in font_face_blocks)
if not has_mono:
# Acceptam si daca e in o regula CSS (nu neaparat @font-face)
has_mono = "IBM Plex Mono" in style
assert has_mono, (
"'IBM Plex Mono' lipseste din <style> (trebuie pentru coduri RAR/VIN/nr)."
)
# ── test_contrast_aa_pe_text_principal ───────────────────────────────────────
def test_contrast_aa_pe_text_principal(client):
"""Contrastul text principal este >= 4.5:1 in dark si light (WCAG 2.1 AA).
Dark: --ink:#e6e9ef pe --bg:#0f1218
Light: --ink:#1a1d24 pe --bg:#f5f7fa
Accent ca text pe alb: #1F66C9 pe #ffffff (WCAG AA pentru text normal)
"""
resp = client.get("/login")
assert resp.status_code == 200
style = _get_style_block(resp.text)
# Extrage valorile de culoare din :root si [data-theme="light"]
def _extract_var(block: str, var_name: str) -> str | None:
m = re.search(
re.escape(var_name) + r"\s*:\s*(#[0-9a-fA-F]{6})",
block,
re.IGNORECASE,
)
return m.group(1) if m else None
root_m = re.search(r":root\s*\{([^}]+)\}", style, re.DOTALL)
assert root_m, "Blocul :root negasit"
root_block = root_m.group(1)
light_m = re.search(r'\[data-theme=["\']light["\']\]\s*\{([^}]+)\}', style, re.DOTALL)
assert light_m, 'Blocul [data-theme="light"] negasit'
light_block = light_m.group(1)
# --- Dark: ink pe bg ---
dark_ink = _extract_var(root_block, "--ink")
dark_bg = _extract_var(root_block, "--bg")
assert dark_ink and dark_bg, (
f"Nu am putut extrage --ink/{dark_ink} sau --bg/{dark_bg} din :root"
)
cr_dark = _contrast_ratio(dark_ink, dark_bg)
assert cr_dark >= 4.5, (
f"Contrast dark insuficient: {dark_ink} pe {dark_bg} = {cr_dark:.2f}:1 (minim 4.5:1 AA)"
)
# --- Light: ink pe bg ---
light_ink = _extract_var(light_block, "--ink")
light_bg = _extract_var(light_block, "--bg")
assert light_ink and light_bg, (
f"Nu am putut extrage --ink/{light_ink} sau --bg/{light_bg} din [data-theme=light]"
)
cr_light = _contrast_ratio(light_ink, light_bg)
assert cr_light >= 4.5, (
f"Contrast light insuficient: {light_ink} pe {light_bg} = {cr_light:.2f}:1 (minim 4.5:1 AA)"
)
# --- Accent ca text pe alb (tema light) ---
light_accent = _extract_var(light_block, "--accent")
assert light_accent, f"--accent negasit in [data-theme=light]: {light_block.strip()}"
cr_accent_white = _contrast_ratio(light_accent, "#ffffff")
assert cr_accent_white >= 4.5, (
f"Accent light ({light_accent}) pe alb: contrast {cr_accent_white:.2f}:1 < 4.5:1 AA. "
f"Foloseste o varianta mai inchisa (ex. #1F66C9)."
)

View File

@@ -153,9 +153,8 @@ def test_mapari_ajutor_disclosure_si_fara_proza_inline(client):
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
# panou Ajutor (<details>) prezent
assert "ajutor-mapari" in html
assert "<details" in html and ">Ajutor<" in html
# US-010: sectiunea de ajutor (<details class="ajutor-mapari">) eliminata
assert "ajutor-mapari" not in html
# antet de coloana compact
assert ">In coada<" in html
# proza inline veche eliminata de pe sectiuni