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 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) GET /v1/import/{id}/export-failed — CSV cu randuri esuate (needs_data/needs_mapping/needs_review)
Reguli cheie (plan §3.1-3.4, §12): Reguli cheie:
- Issue 6: scrieri bulk in tranzactie explicita BEGIN IMMEDIATE...COMMIT + executemany. - Scrieri bulk in tranzactie explicita BEGIN IMMEDIATE...COMMIT + executemany.
- Eng#5: already_sent lookup BATCH (IN chunk ~900), nu N+1. - already_sent lookup BATCH (IN chunk ~900), nu N+1.
- OV-3: duplicate_in_file EXCLUSIV la preview/commit. NU atinge reconcile.py/worker. - 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. - TOCTOU: commit per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
- Issue 5a: import_rows.raw_json CRIPTAT Fernet. - import_rows.raw_json CRIPTAT Fernet.
- Issue 5b: fuzzy coloane refoloseste mapping.normalize_for_match (DRY). - Drift semnatura coloane -> NU aplica orb, cere re-confirmare.
- T4/D3: drift semnatura coloane -> NU aplica orb, cere re-confirmare.
""" """
from __future__ import annotations 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) # Marimea maxima a unui chunk pentru IN(...) SQLite (limite SQLite ~999)
_IN_CHUNK = 900 _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]] = { _CANONICAL_SYNONYMS: dict[str, list[str]] = {
"vin": ["VIN", "Serie sasiu", "Sasiu", "Serie", "Numar sasiu", "Nr sasiu", "Chassis"], "vin": ["VIN", "Serie sasiu", "Sasiu", "Serie", "Numar sasiu", "Nr sasiu", "Chassis"],
"nr_inmatriculare": ["Nr inmatriculare", "Numar inmatriculare", "Numar auto", "Nr auto", "Numar", "Nr"], "nr_inmatriculare": ["Nr inmatriculare", "Numar inmatriculare", "Numar auto", "Nr auto", "Numar", "Nr"],
@@ -93,7 +92,7 @@ def _fuzzy_suggest_column(
) -> list[dict]: ) -> list[dict]:
"""Sugereaza campuri canonice pentru o coloana din fisier. """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. Intoarce [{camp_canonic, score}] sortat descrescator.
""" """
from rapidfuzz import fuzz, process from rapidfuzz import fuzz, process
@@ -140,10 +139,10 @@ def _resolve_row_for_preview(
errors: lista erori validare errors: lista erori validare
flags: motive needs_review flags: motive needs_review
`override` (3.6, Approach B): patch CANONIC editat in preview, aplicat ULTIMUL `override`: patch CANONIC editat in preview, aplicat ULTIMUL peste valorile
peste valorile mapate (dupa `json_mapare` si canonicalizare). Permite corectarea mapate (dupa `json_mapare` si canonicalizare). Permite corectarea unei valori
unei valori sau completarea unui camp a carui coloana LIPSESTE din fisier, fara sau completarea unui camp a carui coloana LIPSESTE din fisier, fara sa atinga
sa atinga `raw_json`/idempotency. `raw_json`/idempotency.
""" """
# Aplica maparea de coloane # Aplica maparea de coloane
mapped: dict[str, Any] = {} mapped: dict[str, Any] = {}
@@ -151,7 +150,7 @@ def _resolve_row_for_preview(
if col_fisier in raw_row and camp_canonic: if col_fisier in raw_row and camp_canonic:
mapped[camp_canonic] = raw_row[col_fisier] 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] = [] formula_flag: list[str] = []
for col_fisier, camp_canonic in json_mapare.items(): for col_fisier, camp_canonic in json_mapare.items():
if col_fisier in formula_columns: 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) 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}] 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) canon = canonicalize_row(mapped)
mapped.update({ mapped.update({
"vin": canon["vin"], "vin": canon["vin"],
@@ -194,7 +193,7 @@ def _resolve_row_for_preview(
"odometru_final": canon["odometru_final"], "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). # canonicalizate. Valorile din override sunt deja canonice (vezi _merge_override).
if override: if override:
mapped.update(override) mapped.update(override)
@@ -230,7 +229,7 @@ def _resolve_row_for_preview(
"flags": all_flags, "flags": all_flags,
} }
# auto_send gate (T6/OV-1) # auto_send gate
if has_no_auto_send(resolved, mapping_meta): if has_no_auto_send(resolved, mapping_meta):
return { return {
"resolved_status": "needs_mapping", "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) return build_key(account_id, canon)
# Campuri de continut editabile in preview (3.6). Operatia/codul RAR NU se editeaza # Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza
# aici (raman in panoul de mapare) — vezi Non-Goals din PRD 3.6. # aici (raman in panoul de mapare).
EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final") 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]: 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. 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"], "id_prezentare": r["id_prezentare"],
"created_at": r["created_at"], "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] legacy_keys_needed = [k for k in chunk if k not in found]
if legacy_keys_needed: if legacy_keys_needed:
lph = ",".join("?" * len(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. """Upload fisier xlsx/csv -> staging in import_batches/import_rows.
Nu trimite nimic la RAR. Intoarce {import_id, columns, sample_rows, sheets?}. Nu trimite nimic la RAR. Intoarce {import_id, columns, sample_rows, sheets?}.
PII (raw_json) criptat Fernet la rest (Issue 5a). PII (raw_json) criptat Fernet la rest. Scrieri bulk in tranzactie explicita.
Scrieri bulk in tranzactie explicita (Issue 6).
""" """
acct = account_or_default(account_id) acct = account_or_default(account_id)
data = await file.read() data = await file.read()
@@ -468,7 +466,7 @@ async def upload_import(
try: try:
sig = _signature(parsed.columns) sig = _signature(parsed.columns)
# Issue 6: tranzactie explicita BEGIN IMMEDIATE + executemany # Tranzactie explicita BEGIN IMMEDIATE + executemany
conn.execute("BEGIN IMMEDIATE") conn.execute("BEGIN IMMEDIATE")
try: try:
# Insert import_batches # Insert import_batches
@@ -482,7 +480,7 @@ async def upload_import(
# Insert import_rows bulk (executemany) cu PII criptat # Insert import_rows bulk (executemany) cu PII criptat
row_params = [] row_params = []
for i, row_dict in enumerate(parsed.rows): 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)) row_params.append((batch_id, i, raw_json_enc, "pending", None))
conn.executemany( conn.executemany(
@@ -506,11 +504,8 @@ async def upload_import(
# Sample rows (primele 3, fara PII) # Sample rows (primele 3, fara PII)
sample = parsed.rows[:3] sample = parsed.rows[:3]
# Persistam metadata parsedata (coercion_flags, date_col_format, formula_columns) # Metadata parsata (coercion_flags etc.) se intoarce in raspuns; preview-ul
# in import_batches pentru refolosire la preview (stocam ca JSON in 'status' nu e OK, # o recalculeaza din raw_json deja stocat.
# 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.
conn.execute( conn.execute(
"UPDATE import_batches SET ok=?, needs_review=? WHERE id=?", "UPDATE import_batches SET ok=?, needs_review=? WHERE id=?",
(0, len(parsed.coercion_flags), batch_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["column_mapping"] = json.loads(existing_mapping["json_mapare"])
result["format_data"] = existing_mapping["format_data"] result["format_data"] = existing_mapping["format_data"]
else: else:
# Sugestii fuzzy per coloana (Issue 5b: refoloseste normalize_for_match) # Sugestii fuzzy per coloana
suggestions: dict[str, list[dict]] = {} suggestions: dict[str, list[dict]] = {}
for col in parsed.columns: for col in parsed.columns:
sugg = _fuzzy_suggest_column(col, limit=3) 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") @router.get("/{import_id}/preview")
@@ -686,8 +681,8 @@ def preview_import(
) -> dict: ) -> dict:
"""Preview 6 stari per rand: ok/needs_mapping/needs_data/needs_review/already_sent/duplicate_in_file. """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 Nu enqueue-aza nimic. Already_sent = lookup batch. Duplicate_in_file = intra-batch
collision (OV-3: EXCLUSIV aici, NU in reconcile.py/worker). collision (EXCLUSIV aici, NU in reconcile.py/worker).
""" """
acct = account_or_default(account_id) acct = account_or_default(account_id)
conn = get_connection() conn = get_connection()
@@ -708,7 +703,7 @@ def preview_import(
if not raw_rows_db: if not raw_rows_db:
return {"rows": [], "summary": {}} return {"rows": [], "summary": {}}
# Decripteaza si reconstruieste randurile + override-urile editate (3.6) # Decripteaza si reconstruieste randurile + override-urile editate
rows: list[dict] = [] rows: list[dict] = []
overrides: list[dict] = [] overrides: list[dict] = []
for r in raw_rows_db: for r in raw_rows_db:
@@ -747,22 +742,18 @@ def preview_import(
json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"]) json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"])
format_data = mapping_row["format_data"] 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_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# 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 valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct) text_rules = load_text_rules(conn, acct)
# Reconstruieste parsed info (coercion_flags si date_col_format) din datele stocate # Recalculam coercion_flags din valorile stocate (nu sunt persistate separat):
# Nota: import_rows stocheaza raw_json dupa coercion (din parse_file) # detectie simpla de VIN numeric.
# Recalculam flags din valorile stocate (coercion_flags nu e stocat separat)
# Vom folosi o detectie simpla: VIN-uri care par numerice si odometru float
coercion_flags_map: dict[int, list[str]] = {} coercion_flags_map: dict[int, list[str]] = {}
# Detectam din valorile stocate
for i, row_dict in enumerate(rows): for i, row_dict in enumerate(rows):
flags = [] flags = []
# Detectam VIN numeric: daca valoarea a fost stocata si arata ca numar
for col_f, camp_c in json_mapare.items(): for col_f, camp_c in json_mapare.items():
if camp_c == "vin": if camp_c == "vin":
vin_val = row_dict.get(col_f) vin_val = row_dict.get(col_f)
@@ -830,11 +821,11 @@ def preview_import(
"idempotency_key": key, "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)) unique_keys = list(set(keys_for_lookup))
already_sent_map = _already_sent_lookup(conn, account_id, unique_keys) 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 # Grupam pe cheie de idempotenta: >1 rand cu aceeasi cheie = duplicate
key_to_indices: dict[str, list[int]] = {} key_to_indices: dict[str, list[int]] = {}
for row in preview_rows: for row in preview_rows:
@@ -857,7 +848,7 @@ def preview_import(
row["already_sent_info"] = sent_info row["already_sent_info"] = sent_info
continue 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, []) 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"): 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"]] 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): class CommitIn(BaseModel):
@@ -929,9 +920,9 @@ def commit_import(
req: CommitIn, req: CommitIn,
account_id: int = Depends(resolve_account_id), account_id: int = Depends(resolve_account_id),
) -> dict: ) -> 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. Randuri colidante = reclasificate already_sent in rezultatul commit-ului.
rows_hash + n_confirmed acopera DOAR randurile efectiv puse in coada. rows_hash + n_confirmed acopera DOAR randurile efectiv puse in coada.
""" """
@@ -981,7 +972,7 @@ def commit_import(
elif r["resolved_status"] == "needs_review": elif r["resolved_status"] == "needs_review":
review_indices.add(r["row_index"]) 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] confirmed_review = [idx for idx in req.reviewed_rows if idx in review_indices]
for idx in confirmed_review: for idx in confirmed_review:
# Gaseste randul needs_review si il adauga la ok_rows # Gaseste randul needs_review si il adauga la ok_rows
@@ -1040,7 +1031,7 @@ def commit_import(
# Incarca maparea de operatii # Incarca maparea de operatii
mapping_meta = load_mapping_meta(conn, acct) mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# 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 valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct) text_rules = load_text_rules(conn, acct)
@@ -1049,10 +1040,9 @@ def commit_import(
toctou_collisions: list[int] = [] toctou_collisions: list[int] = []
rows_for_hash: list[str] = [] rows_for_hash: list[str] = []
# Enqueue in tranzactie explicita (Issue 6) # Enqueue in tranzactie explicita
conn.execute("BEGIN IMMEDIATE") conn.execute("BEGIN IMMEDIATE")
try: try:
# purge_after pentru submissions noi (T16)
purge_after_sql = "datetime('now', '+90 days')" purge_after_sql = "datetime('now', '+90 days')"
for ok_row in ok_rows: for ok_row in ok_rows:
@@ -1100,7 +1090,7 @@ def commit_import(
"odometru_final": canon["odometru_final"], "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 {} override = ok_row.get("override") or {}
if override: if override:
mapped.update(override) mapped.update(override)
@@ -1127,7 +1117,7 @@ def commit_import(
payload_json = json.dumps(mapped, ensure_ascii=False) 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( cur = conn.execute(
"INSERT OR IGNORE INTO submissions " "INSERT OR IGNORE INTO submissions "
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after) " "(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) toctou_collisions.append(row_index)
else: else:
sub_id = cur.lastrowid sub_id = cur.lastrowid
# US-010: telemetrie pentru itemii rezolvati prin regula text.
_emite_text_rule_hits(conn, acct, int(sub_id), resolved) _emite_text_rule_hits(conn, acct, int(sub_id), resolved)
enqueued.append({ enqueued.append({
"submission_id": sub_id, "submission_id": sub_id,
@@ -1155,7 +1144,7 @@ def commit_import(
n_enqueued = len(enqueued) 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( rows_hash = hashlib.sha256(
json.dumps(sorted(rows_for_hash), ensure_ascii=False).encode("utf-8") json.dumps(sorted(rows_for_hash), ensure_ascii=False).encode("utf-8")
).hexdigest() if rows_for_hash else "" ).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): class RandEditIn(BaseModel):
@@ -1205,7 +1194,7 @@ def editeaza_rand(
req: RandEditIn, req: RandEditIn,
account_id: int = Depends(resolve_account_id), account_id: int = Depends(resolve_account_id),
) -> dict: ) -> 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 NU recalculeaza statusul si NU atinge `submissions`; preview-ul rederiva statusul
prin `_resolve_row_for_preview` cu override aplicat ultimul. 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 = [ _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: Endpointuri:
GET /v1/ping — readiness check per cont (autentificat sau dev fallback) 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). - POST /v1/prezentari: enqueue cu idempotenta (dedup pe idempotency_key UNIQUE).
- GET /v1/prezentari, /v1/prezentari/{id}: monitorizare coada. - GET /v1/prezentari, /v1/prezentari/{id}: monitorizare coada.
- GET /v1/nomenclator: cache local. - GET /v1/nomenclator: cache local.
- GET /v1/mapari: listare mapari cont. - 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 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]: 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 [ return [
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} necunoscut/fara mapare RAR")} {**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} necunoscut/fara mapare RAR")}
for u in unmapped for u in unmapped
@@ -87,7 +85,7 @@ def _erori_nemapate(unmapped: list[dict]) -> list[dict]:
def _motiv_clasificare(cl: dict) -> str | None: def _motiv_clasificare(cl: dict) -> str | None:
"""Rezumat uman pe o linie pentru un rezultat de clasificare (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 None cand status='queued'. Acopera toate ramurile de blocaj: erori de continut
(needs_data), coduri nemapate (needs_mapping) si auto_send oprit (needs_mapping). (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: 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) Populeaza erori (validare continut), nemapate (coduri fara mapare) si motiv (uman)
pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None. pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None.
@@ -141,42 +139,40 @@ def create_prezentari(
) -> PrezentariResponse: ) -> PrezentariResponse:
"""Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission. """Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission.
Validarea de continut (T3, app.validation) ruleaza inainte de enqueue: Validarea de continut (app.validation) ruleaza inainte de enqueue: esecurile NU
esecurile NU resping cererea, ci enqueue-aza cu status `needs_data` + motiv resping cererea, ci enqueue-aza cu status `needs_data` + motiv. JSON malformat ->
(plan.md sect. 3). JSON malformat -> 422 din Pydantic (validare de shape). 422 din Pydantic (validare de shape).
account_id vine din cheia API (resolve_account_id): cont real cu cheie, 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. 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 Cand rar_credentials lipseste, submission-ul intra fara creds efemere: worker-ul
cade pe creds-urile durabile ale contului (`accounts.rar_creds_enc`). cade pe creds-urile durabile ale contului (`accounts.rar_creds_enc`).
""" """
acct = account_or_default(account_id) acct = account_or_default(account_id)
# Creds RAR efemere: criptate si lipite de fiecare submission nou pana la # Creds RAR efemere: criptate si lipite de fiecare submission nou pana la
# primul login reusit pentru cont (worker le sterge atunci). Zero-storage at # 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=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 creds_enc = encrypt_creds(req.rar_credentials.model_dump()) if req.rar_credentials else None
conn = get_connection() conn = get_connection()
results: list[SubmissionResult] = [] results: list[SubmissionResult] = []
try: 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_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat. # Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat.
# valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot). # valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot).
valid_codes = load_nomenclator_codes(conn) or None 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) text_rules = load_text_rules(conn, acct)
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error) error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
for prez in req.prezentari: for prez in req.prezentari:
content = prez.model_dump() 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: # build_key aplica account_or_default(account_id) inainte de hash:
# None si 1 colapseaza la aceeasi cheie (canal API + canal import). # None si 1 colapseaza la aceeasi cheie (canal API + canal import).
canon = canonicalize_row(content) canon = canonicalize_row(content)
key = build_key(account_id, canon) 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({ content.update({
"vin": canon["vin"], "vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"], "nr_inmatriculare": canon["nr_inmatriculare"],
@@ -187,7 +183,7 @@ def create_prezentari(
(key,), (key,),
).fetchone() ).fetchone()
if existing: 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 # retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam
# creds + reset), printr-un UPDATE compare-and-swap pe status='error'. # creds + reset), printr-un UPDATE compare-and-swap pe status='error'.
if existing["status"] == "error": if existing["status"] == "error":
@@ -205,17 +201,16 @@ def create_prezentari(
cl["rar_error"], creds_enc, existing["id"]), cl["rar_error"], creds_enc, existing["id"]),
) )
if cur.rowcount == 1: if cur.rowcount == 1:
# Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc, # Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc)
# decizie #17) — ambele canale converg pe parola corectata. # — ambele canale converg pe parola corectata.
if req.rar_credentials is not None: if req.rar_credentials is not None:
conn.execute( conn.execute(
"UPDATE accounts SET rar_creds_enc=? WHERE id=?", "UPDATE accounts SET rar_creds_enc=? WHERE id=?",
(encrypt_creds(req.rar_credentials.model_dump()), acct), (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"]) _emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"])
# Raspuns onest si la reactivare (PRD 5.7): daca re-clasificarea # Raspuns onest si la reactivare: daca re-clasificarea cade pe
# cade pe needs_data/needs_mapping, expune motivul (nu doar status). # needs_data/needs_mapping, expune motivul (nu doar status).
results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True)) results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True))
continue continue
# Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE # Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE
@@ -234,7 +229,7 @@ def create_prezentari(
) )
continue 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). # (canonicalize + mapare op->cod + validare + auto_send gate).
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules) cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
if cl["blocked_error"]: if cl["blocked_error"]:
@@ -247,13 +242,12 @@ def create_prezentari(
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc), (key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc),
) )
sub_id = int(cur.lastrowid) sub_id = int(cur.lastrowid)
# US-010: telemetrie pentru itemii rezolvati prin regula text.
_emite_text_rule_hits(conn, acct, sub_id, cl["resolved"]) _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)) results.append(_rezultat_enqueue(sub_id, cl))
# US-004: audit cerere API per cont. Doar metadate (count + distributie status), # Audit cerere API per cont. Doar metadate (count + distributie status),
# NICIUN camp de payload PII integral. Reuse conn (T4 — fara contentie WAL). # NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL).
dist: dict[str, int] = {} dist: dict[str, int] = {}
for r in results: for r in results:
if r.reactivated: if r.reactivated:
@@ -284,7 +278,7 @@ def valideaza_prezentari(
Intoarce pentru fiecare prezentare: verdictul (status_estimat), erorile de Intoarce pentru fiecare prezentare: verdictul (status_estimat), erorile de
continut si codurile nemapate — exact ce ar obtine trimiterea reala pe acelasi 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) acct = account_or_default(account_id)
conn = get_connection() conn = get_connection()
@@ -301,7 +295,7 @@ def valideaza_prezentari(
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules) res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
if res["blocked_error"]: if res["blocked_error"]:
res = {**res, "status": "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 = [ nemapate = [
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} fara mapare RAR")} {**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} fara mapare RAR")}
for u in res["unmapped"] for u in res["unmapped"]
@@ -329,7 +323,7 @@ def list_prezentari(
try: try:
scope_sql, scope_params = account_scope_clause(account_id) scope_sql, scope_params = account_scope_clause(account_id)
# payload_json e plaintext (vezi submissions.payload_json); il citim doar ca # 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 = ( cols = (
"id, status, id_prezentare, rar_status_code, retry_count, " "id, status, id_prezentare, rar_status_code, retry_count, "
"created_at, updated_at, payload_json" "created_at, updated_at, payload_json"
@@ -357,13 +351,13 @@ def list_prezentari(
conn.close() conn.close()
# Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita (B4). # Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita.
# Exclude: rar_creds_enc, payload_json, idempotency_key, rar_error, sending_since. # Exclude: rar_creds_enc, payload_json, idempotency_key, sending_since.
_PREZENTARE_FIELDS = frozenset({ _PREZENTARE_FIELDS = frozenset({
"id", "status", "id_prezentare", "rar_status_code", "retry_count", "id", "status", "id_prezentare", "rar_status_code", "retry_count",
"next_attempt_at", "created_at", "updated_at", "account_id", "next_attempt_at", "created_at", "updated_at", "account_id",
"batch_id", "row_index", "purge_after", "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 # erori din catalog (niciodata creds, ex. RAR_CREDS_INVALIDE poarta doar cauza
# "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API. # "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API.
"rar_error", "rar_error",
@@ -383,7 +377,7 @@ def get_prezentare(
[submission_id] + scope_params, [submission_id] + scope_params,
).fetchone() ).fetchone()
if not row: 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. # sau nu exista deloc — nu confirmam existenta.
raise HTTPException(status_code=404, detail="submission inexistent") raise HTTPException(status_code=404, detail="submission inexistent")
row_dict = dict(row) row_dict = dict(row)
@@ -397,11 +391,11 @@ def delete_prezentare(
submission_id: int, submission_id: int,
account_id: int = Depends(resolve_account_id), account_id: int = Depends(resolve_account_id),
) -> dict: ) -> 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 Raspuns 200 + body JSON (NU 204 — clienti VFP fac string-parse). Scope evaluat
INAINTEA starii (decizie /autoplan #20): cross-account / inexistent -> 404 (acelasi INAINTEA starii: cross-account / inexistent -> 404 (acelasi mesaj);
mesaj, B3); own-account `sent`/`sending` -> 409 (conflict de stare). own-account `sent`/`sending` -> 409 (conflict de stare).
""" """
conn = get_connection() conn = get_connection()
try: try:
@@ -424,10 +418,10 @@ def repune_prezentare(
submission_id: int, submission_id: int,
account_id: int = Depends(resolve_account_id), account_id: int = Depends(resolve_account_id),
) -> dict: ) -> 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 `error -> queued`, re-ruleaza classify. Acelasi oracol de scope/stare ca DELETE
scope/stare ca DELETE (404 cross-account/inexistent, 409 sent/sending). (404 cross-account/inexistent, 409 sent/sending).
""" """
conn = get_connection() conn = get_connection()
try: 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]. """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 = 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 account_id IS NULL apartin contului 1. b64_image NU intra in CSV.
schelet; b64_image NU intra in CSV.
""" """
scope_sql, scope_params = account_scope_clause(account_id) scope_sql, scope_params = account_scope_clause(account_id)
sql = ( sql = (
@@ -514,7 +507,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a
"submission_id": r["id"], "submission_id": r["id"],
"status": r["status"], "status": r["status"],
"id_prezentare": r["id_prezentare"] or "", "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"]), "account_id": account_or_default(r["account_id"]),
"vin": p.get("vin") or "", "vin": p.get("vin") or "",
"nr_inmatriculare": p.get("nr_inmatriculare") 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); 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 `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() conn = get_connection()
try: try:
@@ -568,7 +561,7 @@ def get_mapari(
"""Maparile operatie->cod ale contului curent. """Maparile operatie->cod ale contului curent.
Parametrul `account_id` din query e pastrat pentru compatibilitate, dar contul 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: if account_id is not None and account_id != key_account:
raise HTTPException( raise HTTPException(
@@ -635,7 +628,7 @@ def create_mapare(
class RarCredsIn(BaseModel): 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) email: str = Field(..., min_length=1)
password: str = Field(..., min_length=1, repr=False) password: str = Field(..., min_length=1, repr=False)
@@ -646,7 +639,7 @@ def set_rar_creds(
req: RarCredsIn, req: RarCredsIn,
account_id: int = Depends(resolve_account_id), account_id: int = Depends(resolve_account_id),
) -> dict: ) -> 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 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). 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: 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 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. (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) - cheie invalida (prezenta) -> 401 (mereu, indiferent de flag)
- fara cheie + flag off -> cont implicit (id=1), back-compat - fara cheie + flag off -> cont implicit (id=1), back-compat
- fara cheie + flag on -> 401 - 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() settings = get_settings()
plaintext = _extract_key(x_api_key, authorization) plaintext = _extract_key(x_api_key, authorization)

View File

@@ -1,8 +1,8 @@
"""Configurare gateway. Env vars (prefix AUTOPASS_) + valori implicite. """Configurare gateway. Env vars (prefix AUTOPASS_) + valori implicite.
NU stocheaza parole RAR. Credentialele RAR vin per-cerere de la ROAAUTO NU stocheaza parole RAR. Credentialele RAR vin per-cerere de la ROAAUTO.
(vezi plan.md sect. 5). Helper-ul `load_test_credentials` citeste blocul Helper-ul `load_test_credentials` citeste blocul <test> din settings.xml DOAR
<test> din settings.xml DOAR pentru dev local / probe pe mediul de test. pentru dev local / probe pe mediul de test.
""" """
from __future__ import annotations from __future__ import annotations
@@ -22,22 +22,21 @@ class Settings(BaseSettings):
# --- Bază de date --- # --- Bază de date ---
db_path: Path = ROOT / "data" / "autopass.db" 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 # Nivel minim al evenimentelor scrise in app_events + log text. Sub el, evenimentul
# e ignorat (best-effort). DEBUG|INFO|WARNING|ERROR|CRITICAL. # e ignorat (best-effort). DEBUG|INFO|WARNING|ERROR|CRITICAL.
log_level: str = "INFO" log_level: str = "INFO"
# Retentie jurnal (app_events) — aliniat cu submissions/import_batches (decizie §5).
log_retention_days: int = 90 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. # Fisier per-proces (app-api.log / app-worker.log) — rotatia nu e multiproces-safe.
log_dir: Path = ROOT / ".run" log_dir: Path = ROOT / ".run"
log_file_max_bytes: int = 5_000_000 log_file_max_bytes: int = 5_000_000
log_file_backup_count: int = 5 log_file_backup_count: int = 5
# Retentie randuri blocate (error/needs_data/needs_mapping). Mai scurt decat 90z # 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 blocked_retention_days: int = 30
# --- Securitate (CORE) --- # --- Securitate ---
# Enforcement auth API-key pe /v1/* protejat. False (dev/test): fara cheie -> # 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 # cont implicit id=1. True (prod): fara cheie valida -> 401. O cheie PREZENTA
# dar invalida da 401 indiferent de flag. # 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())" # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
creds_key: str | None = None 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; # Secret semnat cookie sesiune. None -> efemer la fiecare restart (dev ok;
# in prod seteaza persistent ca si creds_key, altfel cookieurile se invalideaza # in prod seteaza persistent ca si creds_key, altfel cookieurile se invalideaza
# la restart). Genereaza: python -c "import secrets; print(secrets.token_hex(32))" # la restart). Genereaza: python -c "import secrets; print(secrets.token_hex(32))"
session_secret: str | None = None session_secret: str | None = None
# True (IMPLICIT, sigur pentru prod): rutele web fara sesiune -> redirect /login; # 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. # seteaza explicit AUTOPASS_WEB_AUTH_REQUIRED=false.
web_auth_required: bool = True 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. # False (dev): cookie fara Secure, functioneaza pe HTTP.
session_https_only: bool = False session_https_only: bool = False
# --- Notificare email admin la signup (US-012, PRD 3.3b) --- # --- Notificare email admin la signup ---
# Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP); # Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP).
# follow-up cand exista SMTP real configurat in .env.
smtp_host: str | None = None smtp_host: str | None = None
smtp_port: int = 587 smtp_port: int = 587
smtp_user: str | None = None smtp_user: str | None = None
smtp_password: str | None = None smtp_password: str | None = None
smtp_from: 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). # Max cereri POST /signup per IP in fereastra de timp (in-proces, fara dependinta noua).
signup_rate_max: int = 5 signup_rate_max: int = 5
signup_rate_window_s: int = 3600 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_test: str = "https://apps.rarom.ro/test-rar-autopass"
rar_base_url_prod: str = "https://apps.rarom.ro/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 # WAF-ul RAR da 403 fara User-Agent de browser. Toate apelurile httpx il trimit.
# docs/api-rar-contract.md). Toate apelurile httpx il trimit.
http_user_agent: str = "Mozilla/5.0" http_user_agent: str = "Mozilla/5.0"
http_timeout_s: float = 30.0 http_timeout_s: float = 30.0
# --- Worker --- # --- Worker ---
worker_poll_interval_s: float = 5.0 worker_poll_interval_s: float = 5.0
worker_heartbeat_stale_s: int = 30 # /healthz considera worker-ul mort peste atat 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 # Send DEZACTIVAT implicit (nu trimite la RAR). Activeaza-l explicit pentru
# pentru proba end-to-end. Reconcilierea/retry-ul complet = T2. # proba end-to-end.
worker_send_enabled: bool = False worker_send_enabled: bool = False
# Dev: foloseste creds <test> din settings.xml pt login worker. In productie # 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 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_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_base_s: int = 5 # backoff = base * 2^retry (plafonat la max)
worker_retry_max_s: int = 300 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 @property
def rar_base_url(self) -> str: 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: if "rar_creds_enc" not in acc_cols:
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_enc TEXT") conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_enc TEXT")
if "active" not in acc_cols: 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") conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1")
acc_cols.add("active") acc_cols.add("active")
if "status" not in acc_cols: if "status" not in acc_cols:
# Stare de ciclu de viata (5.5). Defensiv idempotent (ca is_admin in 3.3b). # Stare de ciclu de viata. Default 'active' (trece CHECK pe randurile existente),
# Default 'active' (trece CHECK pe randurile existente), apoi derivam din `active`: # apoi derivam din `active`: active=0 -> 'pending'.
# active=0 -> 'pending'. Invariant: active=1 <=> status='active'. # Invariant: active=1 <=> status='active'.
conn.execute( conn.execute(
"ALTER TABLE accounts ADD COLUMN status TEXT NOT NULL DEFAULT 'active' " "ALTER TABLE accounts ADD COLUMN status TEXT NOT NULL DEFAULT 'active' "
"CHECK (status IN ('pending','active','blocked','archived','deleted'))" "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: if "email_verified" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0") 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 # Coloana import_rows.override_json: patch canonic editat in preview, criptat Fernet.
# preview, criptat Fernet. Defensiv idempotent (ca is_admin in 3.3b) — DB create
# inainte de 3.6 nu au coloana.
irows_tbl = conn.execute( irows_tbl = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='import_rows'" "SELECT name FROM sqlite_master WHERE type='table' AND name='import_rows'"
).fetchone() ).fetchone()
@@ -151,7 +149,7 @@ def queue_depth(conn: sqlite3.Connection) -> int:
return int(row["n"]) if row else 0 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( def insert_app_event(
conn: sqlite3.Connection, 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). 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. 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), Singura sursa de adevar care mapeaza fiecare cod de eroare la (problema, fix),
cu un helper care construieste obiectul de eroare pe 3 niveluri: cu un helper care construieste obiectul de eroare pe 3 niveluri:

View File

@@ -1,24 +1,18 @@
"""Cheie de idempotenta = hash de continut canonic. """Cheie de idempotenta = hash de continut canonic.
RAR nu are camp nr. comanda si accepta duplicate -> dedup-ul e in sarcina noastra 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. Hash stabil peste o reprezentare canonica a prezentarii.
Treapta 2 (T9 + OV-2): extrage canonicalize_row + build_key ca helpere publice canonicalize_row + build_key sunt helpere publice partajate intre canalul API si
partajate intre canalul API si canalul import. canalul import:
- canonicalize_row: normeaza VIN/nr/odometru (strip ".0" Excel coercion) INAINTE - 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). - 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 Randuri vechi cu cheie-None nu sunt gasite de build_key nou: dual-lookup la
fara auth). Randurile se stocau sub account_or_default=1, dar cheia includea None. already_sent (cheia noua, apoi build_key_legacy) sau recompute-keys o singura data.
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.
""" """
from __future__ import annotations 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). - data_prestatie: strip (normalizarea la YYYY-MM-DD se face in parser).
- prestatii: pastrate ca-atare (rezolvarea e in resolve_prestatii). - prestatii: pastrate ca-atare (rezolvarea e in resolve_prestatii).
""" """
# VIN
vin = (raw.get("vin") or "").strip().upper() vin = (raw.get("vin") or "").strip().upper()
# Nr. inmatriculare
nr = (raw.get("nr_inmatriculare") or "").strip().upper() nr = (raw.get("nr_inmatriculare") or "").strip().upper()
# Odometru: strip ".0" Excel float coercion # 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: def build_key(account_id: int | None, canon: dict[str, Any]) -> str:
"""SHA-256 partajat canal-API + canal-import. """SHA-256 partajat canal-API + canal-import.
Aplica account_or_default inainte de hash (OV-2): None si 1 colapseaza la Aplica account_or_default inainte de hash: None si 1 colapseaza la aceeasi
aceeasi cheie => acelasi rand logic din canale diferite nu se trimite de doua ori. 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) # Import local ca sa evitam import circular (mapping importa din idempotency via validator)
from .mapping import account_or_default 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. Wrapper backward-compat peste canonicalize_row + build_key.
Exclude obs si b64Image (cosmetice, nu definesc unicitatea declaratiei). Exclude obs si b64Image (cosmetice, nu definesc unicitatea declaratiei).
NOTA: dupa OV-2, account_id=None si account_id=1 produc ACEEASI cheie NOTA: account_id=None si account_id=1 produc ACEEASI cheie (via
(via account_or_default in build_key). Randuri vechi cu cheie-None nu sunt account_or_default in build_key). Randuri vechi cu cheie-None nu sunt
acoperite automat — dual-lookup sau recompute-keys la migrare productie. acoperite automat — dual-lookup sau recompute-keys la migrare productie.
""" """
canon = canonicalize_row(prezentare) 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: def build_key_legacy(account_id: int | None, prezentare: dict[str, Any]) -> str:
"""Cheia in formatul vechi (account_id AS-PASSED, fara canonicalize). """Cheia in formatul vechi (account_id AS-PASSED, fara canonicalize).
Folosita EXCLUSIV pentru dual-lookup la already_sent pe DB cu randuri vechi Folosita EXCLUSIV pentru dual-lookup la already_sent pe DB cu randuri vechi.
(dinainte de T9). Nu folosi pentru randuri noi. Nu folosi pentru randuri noi.
""" """
canonic = { canonic = {
"account_id": account_id, "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 1 — read_only=True: dim-check (FileTooLarge) + detectie multi-sheet.
Trecerea 2 — normal-mode: header + merged cells + body. Trecerea 2 — normal-mode: header + merged cells + body.
Aceasta separare e necesara deoarece openpyxl read_only=True nu vede celule imbinate. 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_ROWS = 5_000
MAX_BYTES = 5 * 1024 * 1024 # 5 MB 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 FORMULA_NONE_RATE = 0.6
# Coloane cheie pentru detectia footer-ului (trim structural) # Coloane cheie pentru detectia footer-ului (trim structural)
@@ -82,7 +82,7 @@ class ParsedFile(NamedTuple):
columns: list[str] # Numele coloanelor detectate (din header) columns: list[str] # Numele coloanelor detectate (din header)
rows: list[dict[str, Any]] # Fiecare rand: {coloana: valoare_bruta} rows: list[dict[str, Any]] # Fiecare rand: {coloana: valoare_bruta}
coercion_flags: dict[int, list[str]] # {row_index: [motive needs_review]} 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"} 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 # Trim footer: elimina randuri trailing unde coloanele cheie sunt goale
raw_rows = _trim_footer(raw_rows, col_names) 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)) 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) 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]] = {} coercion_flags: dict[int, list[str]] = {}
processed_rows: list[dict[str, Any]] = [] processed_rows: list[dict[str, Any]] = []
for i, row_dict in enumerate(raw_rows): 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]: 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]: 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" result[col_name] = "mixed"
continue 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) fmt = _infer_date_format_from_column(str_vals)
result[col_name] = fmt 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: def _infer_date_format_from_column(str_vals: list[str]) -> str:
"""Detecteaza formatul datei dintr-o lista de valori string. """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. Daca toti zi <= 12 -> ambiguu.
""" """
dd_first_evidence = False 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]]: 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: def parse_xlsx(data: bytes, *, sheet_name: str | None = None) -> ParsedFile:
"""Parseaza un fisier XLSX. """Parseaza un fisier XLSX.
Arhitectura 2-treceri (Issue 2): Arhitectura 2-treceri:
1. read_only=True: dim-check + detectie multi-sheet 1. read_only=True: dim-check + detectie multi-sheet
2. normal-mode: header + merged cells + body 2. normal-mode: header + merged cells + body

View File

@@ -1,9 +1,7 @@
"""Aplicatia FastAPI: API v1 + dashboard web + /healthz + /metrics. """Aplicatia FastAPI: API v1 + dashboard web + /healthz + /metrics.
Worker-ul ruleaza ca PROCES SEPARAT (python -m app.worker), NU ca task aici 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"). un worker mort nu trebuie sa lase containerul "sanatos".
Pornire dev: uvicorn app.main:app --reload
""" """
from __future__ import annotations from __future__ import annotations
@@ -44,7 +42,7 @@ from .web.session import AdminRequired, LoginRequired
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
install_log_redaction() install_log_redaction()
# Fail-fast: o cheie Fernet setata dar invalida opreste pornirea cu mesaj clar, # 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() validate_creds_key()
init_db() init_db()
yield yield
@@ -61,7 +59,7 @@ app.add_middleware(
https_only=settings.session_https_only, https_only=settings.session_https_only,
same_site="strict", 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, # OUTERMOST (add_middleware prepend), deci `X-Request-ID` se pune pe TOATE raspunsurile,
# inclusiv 401/404/422/500 produse mai in interior. # inclusiv 401/404/422/500 produse mai in interior.
app.add_middleware(RequestIDMiddleware) app.add_middleware(RequestIDMiddleware)
@@ -97,13 +95,11 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: 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) 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 + `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). 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() request_id = getattr(request.state, "request_id", None) or request_id_var.get()
try: try:
@@ -144,9 +140,8 @@ app.include_router(admin_router)
def healthz() -> dict: def healthz() -> dict:
"""Sanatate: worker viu + ultimul login RAR reusit + adancime coada. """Sanatate: worker viu + ultimul login RAR reusit + adancime coada.
Pica (200 cu ok=False / sau folosit de orchestrator) cand worker-ul e mort Intoarce 200 mereu cu detalii; orchestratorul decide restartul pe campul
-> semnal de restart (plan.md sect. 8). Intoarce 200 mereu cu detalii; `worker_alive`.
orchestratorul decide pe campul `worker_alive`.
""" """
settings = get_settings() settings = get_settings()
conn = get_connection() conn = get_connection()

View File

@@ -1,7 +1,7 @@
"""Mapare operatie ROAAUTO -> cod prestatie RAR + fuzzy lookup pentru editor. """Mapare operatie ROAAUTO -> cod prestatie RAR + fuzzy lookup pentru editor.
Contract (varianta hibrida, decis 2026-06-15): un item de prestatie poate veni Contract (varianta hibrida): un item de prestatie poate veni
fie cu `cod_prestatie` (cod RAR direct, ca pana acum), fie cu `cod_op_service` fie cu `cod_prestatie` (cod RAR direct), fie cu `cod_op_service`
(cod intern ROAAUTO) + `denumire`. La ingestie incercam sa rezolvam codul intern (cod intern ROAAUTO) + `denumire`. La ingestie incercam sa rezolvam codul intern
prin `operations_mapping`; daca nu exista mapare -> submission `needs_mapping` 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 (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 — # Forma: "text_rule:<pattern original al regulii castigatoare>". Payload-harmless —
# RAR citeste doar `cod_prestatie`; `cod_sursa` ramane in payload_json fara efect. # RAR citeste doar `cod_prestatie`; `cod_sursa` ramane in payload_json fara efect.
COD_SURSA_TEXT_RULE_PREFIX = "text_rule:" 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 -> `valid_codes` (cand `valid_codes` e setat), nu intoarcem un cod invalid ->
(None, None, None) (operatia ramane nemapata), coerent cu garda din `resolve_prestatii`. (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 normalizat folosit la match. `auto_send` = flagul regulii castigatoare: cand e
falsy (DEFAULT 0, decizia CEO de siguranta) randul trebuie TINUT pentru verificare falsy (DEFAULT 0, de siguranta) randul trebuie TINUT pentru verificare umana, nu
umana, nu trimis automat la RAR (blast radius substring + FINALIZATA ireversibil). trimis automat la RAR (blast radius substring + FINALIZATA ireversibil).
""" """
if not text_rules: if not text_rules:
return None, None, None return None, None, None
@@ -136,7 +136,7 @@ def _rezolva_din_reguli_text(
def text_rule_hits(resolved: list[dict] | None) -> list[dict]: 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` 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 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]: 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 Overlap = pattern-ul nou normalizat (`normalize_for_match`) e substring al unei
reguli existente SAU invers (oricare directie). Pur, determinist, fara DB. 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 fara cod, fara mapare si fara regula text -> ramane nemapat.
- item cu `cod_prestatie` NECUNOSCUT in nomenclator -> tratat ca operatie de - 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 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 fluxul needs_mapping. RAR accepta NUMAI coduri din nomenclator (coloana
din nomenclator (coloana COD_PRESTATIE max 5 car.); un cod necunoscut da COD_PRESTATIE max 5 car.); un cod necunoscut da HTTP 500 si RECORD PARTIAL
HTTP 500 si RECORD PARTIAL la RAR (terminal) -> nu-l trimitem niciodata raw. la RAR (terminal) -> nu-l trimitem niciodata raw.
Precedenta (stricta): `cod_prestatie` direct valid > mapare exacta `cod_op_service` Precedenta (stricta): `cod_prestatie` direct valid > mapare exacta `cod_op_service`
in `mapping` > reguli text > nemapat. Regulile text se incearca DOAR cand nu exista in `mapping` > reguli text > nemapat. Regulile text se incearca DOAR cand nu exista
@@ -217,8 +217,8 @@ def resolve_prestatii(
unmapped: list[dict] = [] unmapped: list[dict] = []
for item in prestatii or []: for item in prestatii or []:
it = dict(item) it = dict(item)
# Curata adnotarile aditive ale rezolvarii (cod_sursa US-010 + flagul de # Curata adnotarile aditive ale rezolvarii (cod_sursa + flagul de hold pe
# hold pe regula auto_send=0): se recalculeaza de la zero la fiecare rezolvare. # 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 # 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. # un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit.
it.pop("cod_sursa", None) it.pop("cod_sursa", None)
@@ -246,11 +246,11 @@ def resolve_prestatii(
) )
if cod_regula is not None: if cod_regula is not None:
it["cod_prestatie"] = cod_regula it["cod_prestatie"] = cod_regula
# Adnotare aditiva (US-010): marcheaza ca rezolvat-prin-regula cu # Adnotare aditiva: marcheaza ca rezolvat-prin-regula cu pattern-ul
# pattern-ul sursa. Payload-harmless (RAR citeste doar cod_prestatie). # sursa. Payload-harmless (RAR citeste doar cod_prestatie).
it["cod_sursa"] = f"{COD_SURSA_TEXT_RULE_PREFIX}{pattern_regula or ''}" it["cod_sursa"] = f"{COD_SURSA_TEXT_RULE_PREFIX}{pattern_regula or ''}"
# Siguranta CEO (US-001): regula cu auto_send=0 rezolva codul dar # Siguranta: regula cu auto_send=0 rezolva codul dar TINE randul
# TINE randul pentru verificare umana (has_no_auto_send -> True). # pentru verificare umana (has_no_auto_send -> True).
if not auto_send_regula: if not auto_send_regula:
it["regula_fara_autosend"] = True it["regula_fara_autosend"] = True
else: else:
@@ -273,7 +273,7 @@ def account_or_default(account_id: int | None) -> int:
def account_scope_clause(account_id: int) -> tuple[str, list]: def account_scope_clause(account_id: int) -> tuple[str, list]:
"""Fragment SQL + params pentru filtrarea pe cont in tabele cu account_id nullable. """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). Foloseste DOAR pe submissions (account_id NULLABLE).
NU folosi pe operations_mapping (account_id NOT NULL) — acolo WHERE account_id=? simplu. 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]: def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]:
"""{cod_op_service -> {cod_prestatie, auto_send}} pentru un cont. """{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) acct = account_or_default(account_id)
rows = conn.execute( rows = conn.execute(
@@ -379,7 +379,7 @@ def classify_prezentare(
"""Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte. """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 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"}. Intoarce {"status", "rar_error", "resolved", "unmapped", "errors", "content"}.
"content" = copia actualizata (VIN/nr canonicalizat + prestatii rezolvate). "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: 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). """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. 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 La fel pentru un item rezolvat printr-o REGULA TEXT cu auto_send=0 — marcat de
auto_send=0 — marcat de `resolve_prestatii` cu `regula_fara_autosend`. In ambele cazuri `resolve_prestatii` cu `regula_fara_autosend`. In ambele cazuri randul ramane
randul ramane needs_mapping (review manual) pana cand operatorul activeaza „In coada". needs_mapping (review manual) pana cand operatorul activeaza „In coada".
Items cu cod_prestatie direct (nu via cod_op_service/regula) nu sunt afectate. Items cu cod_prestatie direct (nu via cod_op_service/regula) nu sunt afectate.
""" """
for item in resolved: 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. 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 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) nomenclator = load_nomenclator(conn)
if account_id is not None: 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: 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. """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 Telemetrie „ce regula a rezolvat ce submission". Best-effort (log_event inghite
inghite exceptiile). Context = {submission_id, account_id, pattern, cod_prestatie} — exceptiile). Context = {submission_id, account_id, pattern, cod_prestatie} — fara
fara PII (pattern + cod nu sunt PII). Import local: evita orice risc de ciclu la import. PII (pattern + cod nu sunt PII). Import local: evita orice risc de ciclu la import.
""" """
hits = text_rule_hits(resolved) hits = text_rule_hits(resolved)
if not hits: 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. """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 -> 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 motiv), resetand backoff-ul. Daca raman nemapate, ramane `needs_mapping` cu
motivul actualizat. Intoarce {requeued, still_blocked, needs_data, review_manual}. 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 auto_send=0 pe un cod nou-mapat -> nu trece pe 'queued' (ramane 'needs_mapping'
'needs_mapping' cu motiv "review manual"); previne FINALIZATA eronat permanent. cu motiv "review manual"); previne FINALIZATA eronat permanent.
T7: batch_id != None -> scope la seria comitata (NU cross-batch). 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 is None -> re-rezolva toti (canal API, batch_id IS NULL inclus).
""" """
acct = account_or_default(account_id) acct = account_or_default(account_id)
mapping_meta = load_mapping_meta(conn, acct) mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None 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) text_rules = load_text_rules(conn, acct)
if batch_id is not None: 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. # NU atinge randuri din alte batches sau din feed API.
rows = conn.execute( rows = conn.execute(
"SELECT id, payload_json FROM submissions " "SELECT id, payload_json FROM submissions "
@@ -631,8 +631,8 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
).fetchall() ).fetchall()
else: else:
# POST /v1/mapari (save manual): re-rezolva EXCLUSIV canalul API (batch_id IS NULL). # 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 # Salvarea unei mapari NU re-queues randuri din batches de import (cross-batch /
# (cross-batch / cross-feed). Batches de import sunt re-rezolvate doar la commit explicit. # cross-feed). Batches de import sunt re-rezolvate doar la commit explicit.
rows = conn.execute( rows = conn.execute(
"SELECT id, payload_json FROM submissions " "SELECT id, payload_json FROM submissions "
"WHERE status='needs_mapping' AND account_id=? AND batch_id IS NULL", "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 content["prestatii"] = resolved
payload_json = json.dumps(content, ensure_ascii=False) 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) _emite_text_rule_hits(conn, acct, r["id"], resolved)
if unmapped: if unmapped:
@@ -660,7 +660,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
stats["still_blocked"] += 1 stats["still_blocked"] += 1
continue 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): if has_no_auto_send(resolved, mapping_meta):
conn.execute( conn.execute(
"UPDATE submissions SET payload_json=?, rar_error=?, updated_at=datetime('now') WHERE id=?", "UPDATE submissions SET payload_json=?, rar_error=?, updated_at=datetime('now') WHERE id=?",

View File

@@ -1,9 +1,8 @@
"""Modele Pydantic pentru suprafata API. """Modele Pydantic pentru suprafata API.
ATENTIE: validarea completa (regex VIN ^[A-HJ-NPR-Z0-9]{17}$, nrInmatriculare, Aici sunt doar formele de baza + normalizare strip/upper. Validarea completa de
dataPrestatie ∈ [2024-12-01, azi] TZ Bucuresti, R-ODO/I-ODO -> odometruInitial continut (regex VIN, interval data, R-ODO/I-ODO -> odometruInitial, ordine
obligatoriu, odometruInitial <= odometruFinal, normalizare strip/upper) este odometru) este in app.validation.
**T3** — aici sunt doar formele de baza. Vezi plan.md sect. 2 + roadmap T3.
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,7 +19,7 @@ class RarCredentials(BaseModel):
class PrestatieItem(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 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 `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 Pydantic doar NORMALIZEAZA aici (strip/upper pe vin/nrInm). Validarea de
continut (regex VIN, interval data, R-ODO/I-ODO, odometru) e in continut (regex VIN, interval data, R-ODO/I-ODO, odometru) e in
app.validation.validate_prezentare si NU resping cererea — marcheaza app.validation.validate_prezentare si NU resping cererea — marcheaza
`needs_data` (plan.md sect. 3). `needs_data`.
""" """
vin: str vin: str
@@ -102,12 +101,12 @@ class SubmissionResult(BaseModel):
status: str status: str
id_prezentare: int | None = None id_prezentare: int | None = None
deduped: bool = False # True daca idempotency a intors un submission existent 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 # Camp aditiv. True cand un rand `error` cu aceeasi cheie de continut a fost
# cheie de continut a fost RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. # RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. `deduped` pastreaza
# `deduped` pastreaza semantica actuala (clientii vechi care testeaza `deduped` nu se sparg). # semantica actuala (clientii vechi care testeaza `deduped` nu se sparg).
reactivated: bool = False reactivated: bool = False
# Raspuns ONEST pentru randuri blocate (PRD 5.7): orice status != 'queued' isi # Raspuns ONEST pentru randuri blocate: orice status != 'queued' isi expune
# expune motivul, ca integratorul sa nu trateze un needs_data/needs_mapping drept succes. # 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}]. # erori = validare de continut (needs_data), 3 niveluri [{field, cod, problema, cauza, fix, message}].
# Pe ramura on_unmapped_error='error' pastreaza COD_NEMAPAT (compat). # Pe ramura on_unmapped_error='error' pastreaza COD_NEMAPAT (compat).
# nemapate = coduri fara mapare RAR (needs_mapping / respins), 3 niveluri + cod_op_service/denumire. # nemapate = coduri fara mapare RAR (needs_mapping / respins), 3 niveluri + cod_op_service/denumire.
@@ -122,7 +121,7 @@ class PrezentariResponse(BaseModel):
class ValidarePrezentariRequest(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 rar_credentials: RarCredentials | None = None
prezentari: list[PrezentareIn] = Field(..., min_length=1) 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, Singurul punct prin care se emit evenimente de aplicatie: garanteaza format,
redactare si dublul canal (app_events in DB + log text rotativ) consistente si 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 imposibil de ocolit. Best-effort: o cadere a jurnalului NU doboara cererea/worker-ul.
doboara cererea/worker-ul.
Redactare la SCRIERE (nu la afisare): toate valorile trec prin `redact_pii` 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 from __future__ import annotations
@@ -22,8 +21,8 @@ from .config import get_settings
from .db import get_connection, insert_app_event from .db import get_connection, insert_app_event
from .security import redact_pii, scrub_text from .security import redact_pii, scrub_text
# request_id al cererii curente (US-002). Setat de middleware-ul HTTP; disponibil # request_id al cererii curente. Setat de middleware-ul HTTP; disponibil in
# in handlerul de erori (US-001) si aici, fara a polua semnaturile de functii. # handlerul de erori si aici, fara a polua semnaturile de functii.
request_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( request_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"request_id", default=None "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} _LEVELS = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "WARN": 30, "ERROR": 40, "CRITICAL": 50}
# Sursa implicita a evenimentelor pentru procesul curent. API = 'api' (default); # 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" _DEFAULT_SOURCE = "api"
_loggers: dict[str, logging.Logger] = {} _loggers: dict[str, logging.Logger] = {}
@@ -46,9 +45,9 @@ def set_source(sursa: str) -> None:
def _text_logger(sursa: str) -> logging.Logger: def _text_logger(sursa: str) -> logging.Logger:
"""Logger cu RotatingFileHandler pe fisier per-proces (app-<sursa>.log). """Logger cu RotatingFileHandler pe fisier per-proces (app-<sursa>.log).
Rotatia pe dimensiune e in aplicatie (decizie §5) — nu depindem de deploy. Rotatia pe dimensiune e in aplicatie — nu depindem de deploy. Cheia de cache
Cheia de cache include calea: la schimbarea log_dir (teste) se creeaza un logger include calea: la schimbarea log_dir (teste) se creeaza un logger nou, fara a
nou, fara a acumula handlere duplicate pe acelasi fisier. acumula handlere duplicate pe acelasi fisier.
""" """
settings = get_settings() settings = get_settings()
path = settings.log_dir / f"app-{sursa}.log" path = settings.log_dir / f"app-{sursa}.log"
@@ -94,10 +93,10 @@ def log_event(
) -> None: ) -> None:
"""Emite un eveniment: un rand `app_events` + o linie in logul text (acelasi continut redactat). """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. - `nivel`: DEBUG|INFO|WARNING|ERROR|CRITICAL. Sub `AUTOPASS_LOG_LEVEL` -> ignorat.
- `context`: metadate (submission_id, count, status...) — NU payload PII integral. - `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. None -> deschide/inchide o conexiune proprie.
Best-effort: orice exceptie e inghitita (jurnalul nu trebuie sa rupa fluxul). 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 Helper PUR partajat intre canalul web (dashboard Trimiteri) si canalul API
(`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua (decizie (`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: exclusiv din cod_prestatie (NU fallback la cod_op_service); uppercase + strip ".0"
cod_rar = _clean_cod_rar(item.get("cod_prestatie")) 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 { return {
"vehicul_nr": nr or EMPTY, "vehicul_nr": nr or EMPTY,
"vin": vin 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, "odometru": odo or EMPTY,
"cod": cod or EMPTY, "cod": cod or EMPTY,
"cod_rar": cod_rar 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 # 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"}) 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 Inchide lacuna: un rand `error` (creds RAR gresite) ar ramane altfel permanent si
permanent si nereparabil. Aceste helpere adauga DOUA tranzitii controlate — nereparabil. Aceste helpere adauga DOUA tranzitii controlate — stergere de randuri
stergere de randuri ne-sent si `blocate -> queued` (re-clasificat) — fara a atinge ne-sent si `blocate -> queued` (re-clasificat) — fara a atinge logica de trimitere
logica de trimitere a worker-ului. a worker-ului.
Invariante (decizii §2 + /autoplan #20): Invariante:
- Opereaza DOAR pe `error`/`needs_data`/`needs_mapping`. `sent` (dovada de trimitere - Opereaza DOAR pe `error`/`needs_data`/`needs_mapping`. `sent` (dovada de trimitere
la RAR, audit) si `sending` (lease worker in zbor) sunt INTERZISE. la RAR, audit) si `sending` (lease worker in zbor) sunt INTERZISE.
- Scope-ul (apartenenta la cont) se evalueaza INAINTEA starii: un rand inexistent SAU - 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). 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 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` `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 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 Ridica SubmissionNotFound / SubmissionStateConflict. Intoarce
{"submission_id", "status_anterior", "status_nou"}. {"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 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 secrets.token_bytes(16). Parametrii scrypt stocati ca eticheta de versiune pentru
migrare cost viitoare (C9). migrare cost viitoare.
""" """
from __future__ import annotations 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. """Seteaza/sterge rolul admin pe toti userii contului dat.
Ridica ValueError daca contul nu exista. 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() acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
if not acct: 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]: 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( rows = conn.execute(
"SELECT email FROM users WHERE is_admin=1" "SELECT email FROM users WHERE is_admin=1"
).fetchall() ).fetchall()

View File

@@ -1,4 +1,4 @@
"""Panou admin web /admin. US-011 PRD 3.3b. """Panou admin web /admin.
Rute: Rute:
GET /admin — listeaza conturi in asteptare + active (require_admin) 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) emails = _emails_by_account(conn)
for acct in accounts: for acct in accounts:
acct["email"] = emails.get(acct["id"]) 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. # 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] 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] 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: 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). (id=1) sau inexistente ridica ValueError din helperi -> sarite (nu opresc bulk-ul).
`action`: activate | block | archive | delete.""" `action`: activate | block | archive | delete."""
for aid in ids: 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): 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.""" Evita 4 handlere copy-paste care difera doar prin verb."""
require_admin(request) require_admin(request)
verify_csrf(request, csrf_token) 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 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: Contract pentru rutele POST web:
- Formulare HTML includ: <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> - Formulare HTML includ: <input type="hidden" name="csrf_token" value="{{ csrf_token }}">

View File

@@ -1,9 +1,6 @@
""" """Traducere stari tehnice in text uman + clasa CSS.
labels.py — traducere stari tehnice in text uman + clasa CSS (US-001, PRD 3.4).
Functii pure: fara DB, fara request. Usor de testat unitar si de importat in template-uri. 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 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 # Dict propriu — NU element in tuple Eticheta (ar rupe template-urile care
# despacheteaza 3 elemente). eticheta_stare ramane neatinsa. # 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: 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: 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]: 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 "", "cauza": e.get("cauza") or e.get("message") or "",
"fix": e.get("fix") or "", "fix": e.get("fix") or "",
"field": e.get("field"), "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"), "cod": e.get("cod"),
}) })
else: else:
@@ -305,7 +302,7 @@ def parse_erori(rar_error: object) -> list[dict]:
"cauza": data.get("cauza") or "", "cauza": data.get("cauza") or "",
"fix": data.get("fix") or "", "fix": data.get("fix") or "",
"field": data.get("field"), "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"), "cod": data.get("cod"),
}] }]
# Dict vechi: unmapped # 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 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) 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 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). `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). 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: (acela nu scurtcircuiteaza handler-ul — FastAPI continua executia). In schimb:
- require_login() RIDICA LoginRequired - require_login() RIDICA LoginRequired
- app.main inregistreaza @app.exception_handler(LoginRequired) care intoarce - 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: 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") val = request.session.get("user_id")
return int(val) if val is not None else None 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: 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.clear()
request.session["account_id"] = account_id request.session["account_id"] = account_id
request.session["user_id"] = user_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> </div>
{% endif %} {% endif %}
{# US-001 (5.5): randul "Ajutor" (wayfinding Mapari/Coduri RAR) eliminat — navigarea {# Sectiunea Trimiteri, permanenta sub upload. Suprimata la first-run (zero
traieste in tab-bar (Mapari) si in meniul de cont (Nomenclator etc.). #} trimiteri): bara de upload acopera deja CTA-ul, iar empty-state-ul ar fi redundant. #}
{# === 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). === #}
{% if are_trimiteri %} {% if are_trimiteri %}
{% include '_coada.html' %} {% include '_coada.html' %}
{% endif %} {% endif %}

View File

@@ -1,8 +1,6 @@
{# {#
_coada.html — repurposat in 3.6 (US-003). _coada.html — sectiunea "Trimiterile tale" inclusa pe Acasa, sub zona de upload.
Nu mai e un tab/panou separat: e sectiunea "Trimiterile tale" inclusa pe Acasa, Filtre + tabel (_submissions.html); detaliul se deschide in modalul global (#modal-detaliu).
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).
#} #}
<section id="trimiteri-section" aria-labelledby="trimiteri-heading" <section id="trimiteri-section" aria-labelledby="trimiteri-heading"
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);"> style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);">
@@ -21,28 +19,17 @@
</span> </span>
</div> </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" <form id="filtre-trimiteri"
hx-get="/_fragments/submissions" hx-get="/_fragments/submissions"
hx-target="#submissions-wrap" hx-target="#submissions-wrap"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-trigger="submit, change, keyup delay:400ms from:input[name='vehicul']" hx-trigger="submit, change, keyup delay:400ms from:input[name='vehicul']"
style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;"> style="display:flex; gap:10px 14px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
<div> <input type="hidden" id="f-status" name="status" value="{{ status_filtru | default('', true) }}">
<label for="f-status" class="muted" style="display:block; font-size:12px;">Stare</label> {# Pagina curenta — actualizata prin OOB swap din _submissions.html; inclusa la reincarcari. #}
{# US-014/T13: status_filtru (din deep-link ?tab=acasa&status=) pre-selecteaza <input type="hidden" id="f-page" name="page" value="1">
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>
<div> <div>
<label for="f-vehicul" class="muted" style="display:block; font-size:12px;">Vehicul (nr/VIN)</label> <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;"> <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"> <input id="f-data-pana" type="date" name="data_pana">
</div> </div>
<button type="submit">Filtreaza</button> <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> </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" <div id="submissions-wrap"
hx-get="/_fragments/submissions" 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"> hx-include="#filtre-trimiteri" hx-swap="innerHTML">
<div class="empty">se incarca…</div> <div class="empty">se incarca…</div>
</div> </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> </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). Primeste o lista de dict-uri cu cheile: problema, cauza, fix, field (sau None).
Afiseaza 3 niveluri intr-un bloc scannabil: 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/ 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"> <section id="jurnal-section" aria-labelledby="jurnal-heading">
<div class="card"> <div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;"> <div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">

View File

@@ -1,18 +1,14 @@
{# Macro-uri partajate intre template-urile de import si mapari. #} {# 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 {# Comutator COMPACT "In coada" — Manual <-> Auto pe un singur rand.
proza inline (explicatia traieste o singura data in panoul Ajutor al cardului Mapari). INVARIANT BACKEND: control = checkbox cu `name="auto_send" value="true"` si
Inlocuieste varianta verbose din 3.6 care injecta 3 randuri de text pe FIECARE linie de SEMANTICA DE PREZENTA (bifat -> trimite "true" -> True; nebifat -> camp absent -> False).
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).
E singura forma compatibila cu AMBELE parsere: `Form(bool)` la /mapari SI `bool(form.get())` 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 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 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). - 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='') -%} {% macro autosend_toggle(form_id='', checked=True, label='') -%}
<label class="autosend-toggle" <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." 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 /* 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. */ 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; } #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) { @media (max-width:767px) {
#mapari-section td select, #mapari-section td input[type=text] { max-width:none; min-width:0; } #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) --> <!-- Sectiunea 1: De rezolvat (operatii needs_mapping) -->
<!-- ============================================================ --> <!-- ============================================================ -->
<div class="card"> <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> <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 %} {% if 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 %}
<div data-dt="10"> <div data-dt="10">
<div class="dt-tools"> <div class="dt-tools">
<input type="search" data-dt-search class="dt-search" <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. Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand.
</div> </div>
{% else %} {% else %}
{# US-005 (5.5): proza explicativa mutata in panoul Ajutor de la "De rezolvat" (o singura data). #}
<div data-dt="10"> <div data-dt="10">
<div class="dt-tools"> <div class="dt-tools">
<input type="search" data-dt-search class="dt-search" <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) }} {{ ui.autosend_toggle(form_id="map-salv-" ~ loop.index, checked=m.auto_send) }}
</td> </td>
<td style="text-align:right; white-space:nowrap;" data-eticheta="Actiuni"> <td style="text-align:right; white-space:nowrap;" data-eticheta="Actiuni">
{# Salveaza/Sterge in meniu contextual (kebab) — randul ramane ingust. Butoanele se {# Butoane icon mereu vizibile (fara kebab). SVG aria-hidden; aria-label pe buton.
leaga prin form= de cele doua form-uri hx-post definite in prima celula a randului. #} data-dirty-form e citit de JS din base.html: la schimbarea select-ului din acelasi rand,
<details class="kebab"> JS adauga clasa "dirty" pe butonul de salvare (fundal --accent = modificari nesalvate). #}
<summary aria-label="Actiuni pentru {{ m.cod_op_service }}">&#8943;</summary> <button type="submit" form="map-salv-{{ loop.index }}"
<div class="kebab-menu"> class="icon-btn"
<button type="submit" form="map-salv-{{ loop.index }}">Salveaza</button> data-dirty-form="map-salv-{{ loop.index }}"
<button type="submit" form="map-del-{{ loop.index }}" class="danger">Sterge</button> aria-label="Salveaza maparea pentru {{ m.cod_op_service }}">
</div> <svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
</details> <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> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -187,79 +174,7 @@
</div> </div>
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- Sectiunea 3: Formate de coloane salvate (column_mappings) --> <!-- Sectiunea 3: Reguli automate pe text (operation_text_rules) -->
<!-- ============================================================ -->
<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) -->
<!-- ============================================================ --> <!-- ============================================================ -->
<div class="card"> <div class="card">
<h2 style="font-size:15px; margin:0 0 8px;">Reguli automate (text)</h2> <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> <button type="submit" form="rt-add">Adauga</button>
</td> </td>
</tr> </tr>
{# Preview pre-salvare (US-009): cate operatii nemapate potriveste pattern-ul. #} {# Preview pre-salvare: cate operatii nemapate potriveste pattern-ul. #}
<tr> <tr>
<td colspan="4" style="padding-top:0;"> <td colspan="4" style="padding-top:0;">
<div id="rt-preview" aria-live="polite"></div> <div id="rt-preview" aria-live="polite"></div>
@@ -354,4 +269,76 @@
</div> </div>
</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> </div>

View File

@@ -1,7 +1,5 @@
{# US-002 (5.5): aceeasi grila standard ca tabelul Trimiteri (_submissions.html): {# Aceeasi grila standard ca tabelul Trimiteri: cod in .pill, denumire ca text normal
.tablewrap > table, antet th standard (mostenit din base.html), cod in .pill, (singura coloana care se poate rupe pe randuri inguste), empty-state in .empty. #}
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. #}
{% if rows %} {% if rows %}
<div class="tablewrap"> <div class="tablewrap">
<table> <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> </div>
{% endif %} {% 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 = [ {% set status_labels = [
('ok', 'gata de trimis'), ('ok', 'gata de trimis'),
('needs_review', 'verifica valori'), ('needs_review', 'verifica valori'),
@@ -108,7 +108,7 @@
{% endif %} {% endif %}
<!-- Tabel preview. Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form, <!-- 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=. --> needs_review se asociaza la #confirm-form prin atributul form=. -->
<div class="tablewrap"> <div class="tablewrap">
<table> <table>
@@ -142,7 +142,7 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<div class="sticky-bar"> <div class="sticky-bar">
<div style="flex:1; min-width:280px;"> <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;" <div class="banner warn" style="margin-bottom:10px; padding:8px 12px; border-radius:6px;"
role="note" aria-live="polite"> role="note" aria-live="polite">
Confirmand, TU esti declarantul acestor Confirmand, TU esti declarantul acestor
@@ -199,7 +199,7 @@
</form> </form>
<!-- Contor "gata de trimis" citit din DOM (data-ok), ca OOB swap-ul de la editare <!-- 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> <span id="preview-ok-count" data-ok="{{ summary.get('ok', 0) }}" hidden></span>
<div style="padding:8px 0 4px;"> <div style="padding:8px 0 4px;">
@@ -212,13 +212,13 @@
<script> <script>
(function() { (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). */ ascunde sectiunea Trimiteri de pe Acasa (se reveleaza la reset/commit din _upload.html). */
var trim = document.getElementById('trimiteri-section'); var trim = document.getElementById('trimiteri-section');
if (trim) trim.style.display = 'none'; if (trim) trim.style.display = 'none';
/* nOk se citeste din DOM (#preview-ok-count[data-ok]) ca OOB swap-ul de la editare /* 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() { function getOk() {
var el = document.getElementById('preview-ok-count'); var el = document.getElementById('preview-ok-count');
return el ? parseInt(el.dataset.ok || '0', 10) : 0; return el ? parseInt(el.dataset.ok || '0', 10) : 0;
@@ -231,7 +231,7 @@
var inp = document.getElementById('n-confirmat'); var inp = document.getElementById('n-confirmat');
var disp = document.getElementById('n-display'); var disp = document.getElementById('n-display');
var btn = document.getElementById('confirm-btn'); var btn = document.getElementById('confirm-btn');
/* Nu re-activa confirm cat un rand e in editare (mutual-exclusion D-3.2). */ /* Nu re-activa confirm cat un rand e in editare (mutual-exclusion). */
var editing = document.querySelector('tr[data-editing="1"]') !== null; var editing = document.querySelector('tr[data-editing="1"]') !== null;
if (inp) inp.value = total; if (inp) inp.value = total;
if (disp) disp.textContent = total; if (disp) disp.textContent = total;

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: Doua moduri:
- display (editing falsy): <tr> normal + buton "Editeaza" pe coloana de actiuni. - 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 - 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. (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). La save, optional OOB pe rezumat + contor "gata de trimis" (include_oob).
#} #}
{%- set res = row.resolved -%} {%- set res = row.resolved -%}
@@ -80,7 +80,7 @@
</tr> </tr>
<script> <script>
(function() { (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'); var btn = document.getElementById('confirm-btn');
if (btn) { btn.disabled = true; btn.title = 'Termina editarea randului inainte de a trimite.'; } if (btn) { btn.disabled = true; btn.title = 'Termina editarea randului inainte de a trimite.'; }
document.querySelectorAll('.btn-editeaza').forEach(function(b) { b.disabled = true; }); document.querySelectorAll('.btn-editeaza').forEach(function(b) { b.disabled = true; });
@@ -152,7 +152,7 @@
</td> </td>
</tr> </tr>
{% if include_oob %} {% if include_oob %}
{# OOB: actualizeaza rezumatul si contorul "gata de trimis" dupa save, fara a re-randa sectiunea (D-3.1). #} {# OOB: actualizeaza rezumatul si contorul "gata de trimis" dupa save, fara a re-randa sectiunea. #}
{% set status_labels = [ {% set status_labels = [
('ok','gata de trimis'), ('needs_review','verifica valori'), ('needs_mapping','fara cod RAR'), ('ok','gata de trimis'), ('needs_review','verifica valori'), ('needs_mapping','fara cod RAR'),
('needs_data','date lipsa'), ('already_sent','deja trimis'), ('duplicate_in_file','dublicat in fisier')] %} ('needs_data','date lipsa'), ('already_sent','deja trimis'), ('duplicate_in_file','dublicat in fisier')] %}

View File

@@ -47,38 +47,6 @@
</span> </span>
</div> </div>
<!-- Necesita atentia ta (US-014): categorii actionabile — link la lista filtrata {# Pill-urile de stare s-au mutat in bara de filtre din sectiunea Trimiteri (_coada.html). #}
+ 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 %}
</div> </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 %} {% 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). #} (gestionabil); sent/sending/queued nu au checkbox (read-only). #}
<form id="bulk-trimiteri" <form id="bulk-trimiteri"
hx-post="/trimiteri/sterge-bulk" hx-post="/trimiteri/sterge-bulk"
@@ -29,9 +43,8 @@
</tr></thead> </tr></thead>
<tbody> <tbody>
{% for r in rows %} {% for r in rows %}
{# PRD 5.9 US-003: randul declanseaza deschiderea MODALULUI global (#detaliu-modal-body), {# Randul declanseaza deschiderea MODALULUI global (#detaliu-modal-body).
nu un rand-sibling. Clickabil/focusabil (role=button); Enter/Space deschid modalul Clickabil/focusabil (role=button); Enter/Space deschid modalul (JS in base.html). #}
(JS in base.html). Vechiul rand-sibling de detaliu a fost eliminat. #}
<tr id="trimitere-row-{{ r.id }}" <tr id="trimitere-row-{{ r.id }}"
class="trimitere-row" class="trimitere-row"
data-detaliu-id="{{ r.id }}" data-detaliu-id="{{ r.id }}"
@@ -51,8 +64,8 @@
<td class="col-id muted" data-eticheta="#">{{ r.id }}</td> <td class="col-id muted" data-eticheta="#">{{ r.id }}</td>
<td class="col-stare" data-eticheta="Stare"> <td class="col-stare" data-eticheta="Stare">
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span> <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` {# Eticheta umana scurta sub pill — text mic, `s-error` pe error/needs_*
pe error/needs_* (singurele stari pe care `eticheta_problema` e ne-goala). (singurele stari pe care `eticheta_problema` e ne-goala).
Stare transmisa prin TEXT, nu doar culoare. Codul brut ramane in modal. #} Stare transmisa prin TEXT, nu doar culoare. Codul brut ramane in modal. #}
{% if r.eticheta_problema %} {% if r.eticheta_problema %}
<div class="eticheta-problema s-error">{{ r.eticheta_problema }}</div> <div class="eticheta-problema s-error">{{ r.eticheta_problema }}</div>
@@ -61,13 +74,14 @@
<td class="col-vehicul" data-eticheta="Vehicul"> <td class="col-vehicul" data-eticheta="Vehicul">
{{ r.prez.vehicul_nr }} {{ r.prez.vehicul_nr }}
{% if r.prez.vin_scurt and r.prez.vin_scurt != '—' %} {% 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 %} {% endif %}
</td> </td>
<td class="col-operatie" data-eticheta="Operatie"> <td class="col-operatie" data-eticheta="Operatie">
<div>{{ r.prez.operatie }}</div> <div>{{ r.prez.operatie }}</div>
{# PRD 5.9 US-002: doar codul RAR (ex. OE-2), FARA prefixul "cod RAR:" — chip {# Doar codul RAR (ex. OE-2), FARA prefixul "cod RAR:" — chip muted discret;
muted discret; cand nemapat afiseaza "nemapat" muted (comportament 5.8). #} cand nemapat afiseaza "nemapat" muted. #}
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %} {% 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> <div class="cod-rar-sub"><span class="cod-rar-cod">{{ r.prez.cod_rar }}</span></div>
{% else %} {% else %}
@@ -83,6 +97,105 @@
</table> </table>
</div> </div>
</form> </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 %} {% elif filtru_activ %}
<div class="empty"> <div class="empty">
Nimic pe filtrul curent. Nimic pe filtrul curent.

View File

@@ -1,14 +1,13 @@
{% from "_eroare.html" import card_erori %} {% from "_eroare.html" import card_erori %}
{% import '_macros.html' as ui %} {% import '_macros.html' as ui %}
{# PRD 5.9 US-004: detaliu editabil in-place, butoane consolidate, ordine verticala R10. {# Detaliu editabil in-place. Fragmentul se swap-uieste in corpul modalului global
Fragmentul se swap-uieste in corpul modalului global (#detaliu-modal-body). Heading-ul (#detaliu-modal-body). Heading-ul poarta id-ul folosit de aria-labelledby al dialogului.
poarta id-ul folosit de aria-labelledby al dialogului. Operatie + cod RAR rezolvat apar IMPREUNA, read-only, folosind `prez.cod_rar`
R9: operatie + cod RAR rezolvat apar IMPREUNA, read-only, folosind `prez.cod_rar`
(fallback „nemapat"), fara eticheta separata „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' %} {% 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;"> <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;"> <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> <h2 id="detaliu-modal-titlu" style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
<span class="pill {{ stare_css }}">{{ stare_text }}</span> <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> <p class="muted" style="margin:0 0 12px; font-size:13px;">{{ stare_subtext }}</p>
{% endif %} {% endif %}
{# === R10 (2): bloc eroare blocanta cand exista === #} {# === Bloc eroare blocanta — DOAR in read-only.
{% if erori_3n %} 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;"> <div style="margin:0 0 14px;">
{{ card_erori(erori_3n) }} {{ card_erori(erori_3n) }}
</div> </div>
{% endif %} {% 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 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. === #} (cod_afis = nemapat), iar aici e picker-ul; dupa mapare, re-render arata codul rezolvat. === #}
{% if nemapate_inline %} {% if nemapate_inline %}
@@ -76,7 +77,7 @@
</div> </div>
{% endif %} {% 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 Zero dublare: campurile vehiculului apar O SINGURA DATA — editabile cand randul e
corectabil, altfel read-only. Operatie + cod RAR read-only deasupra campurilor. === #} corectabil, altfel read-only. Operatie + cod RAR read-only deasupra campurilor. === #}
{% if editabil %} {% if editabil %}
@@ -88,6 +89,13 @@
{% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div> {% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div>
{% endif %} {% 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') %} {% macro camp(nume, eticheta, valoare, tip='text') %}
<div style="margin-bottom:10px;"> <div style="margin-bottom:10px;">
<label for="c-{{ nume }}" class="muted" style="font-size:12px; display:block;">{{ eticheta }}</label> <label for="c-{{ nume }}" class="muted" style="font-size:12px; display:block;">{{ eticheta }}</label>
@@ -105,11 +113,40 @@
hx-disabled-elt="find button"> hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{# 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 style="margin:0 0 12px;">
<div class="muted" style="font-size:12px;">Operatie</div> <div class="muted" style="font-size:12px;">Operatie</div>
<div>{{ prez.operatie }} &middot; {{ cod_afis }}</div> <div>{{ prez.operatie }} &middot; {{ cod_afis }}</div>
</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. #} {# Nr. inmatriculare pe rand propriu, VIN dedesubt — ambele latime plina. #}
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr) }} {{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr) }}
@@ -122,7 +159,7 @@
{{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial) }} {{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial) }}
</div> </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. === #} -> „Salveaza si retrimite" pe /corecteaza. UN SINGUR buton primar per stare. === #}
<div style="margin-top:14px;"> <div style="margin-top:14px;">
<button type="submit">Salveaza si retrimite</button> <button type="submit">Salveaza si retrimite</button>
@@ -139,25 +176,49 @@
<div style="word-break:break-all;">{{ prez.vin }}</div> <div style="word-break:break-all;">{{ prez.vin }}</div>
</div> </div>
<div><div class="muted" style="font-size:12px;">Operatie</div><div>{{ prez.operatie }} &middot; {{ cod_afis }}</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;">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><div class="muted" style="font-size:12px;">Odometru final</div><div>{{ prez.odometru }}</div></div>
</div> </div>
{% endif %} {% 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 %} {% if status == 'error' or gestionabil %}
<div class="detaliu-actiuni-jos" style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);"> <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' %} {% if status == 'error' %}
<form hx-post="/trimitere/{{ id }}/repune" <form hx-post="/trimitere/{{ id }}/repune"
hx-target="#detaliu-modal-body" hx-swap="innerHTML" hx-target="#detaliu-modal-body" hx-swap="innerHTML"
hx-disabled-elt="find button" style="margin:0 0 10px;"> hx-disabled-elt="find button" style="margin:0 0 10px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <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> <button type="submit">Re-pune in coada</button>
</form> </form>
{% endif %} {% 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 %} {% if gestionabil %}
<form hx-post="/trimitere/{{ id }}/sterge" <form hx-post="/trimitere/{{ id }}/sterge"
hx-target="#detaliu-modal-body" hx-swap="innerHTML" hx-target="#detaliu-modal-body" hx-swap="innerHTML"
@@ -173,7 +234,7 @@
</div> </div>
{% endif %} {% endif %}
{# === R10 (6): Detalii tehnice — colapsat implicit === #} {# === Detalii tehnice — colapsat implicit === #}
<details style="margin-top:14px;"> <details style="margin-top:14px;">
<summary class="muted" style="font-size:12px; cursor:pointer;">Detalii tehnice</summary> <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;"> <div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:10px 24px; margin-top:10px;">
@@ -195,7 +256,6 @@
{% endif %} {% endif %}
</details> </details>
</div> </div>
{# PRD 5.9 US-004 (R4): scriptul inline vechi (marcheazaDetaliuDeschis / scrollIntoView pe {# Focus-ul post-swap (incl. re-render corectie/mapare) e gestionat de htmx:afterSettle pe
randul-sibling) a fost eliminat de US-003. Focus-ul post-swap (incl. re-render corectie/ #detaliu-modal-body din base.html. Inchiderea modalului pe succes (queued/sterge) vine
mapare) e gestionat de htmx:afterSettle pe #detaliu-modal-body din base.html. R5: inchiderea din HX-Trigger `inchideModal` emis de rute. #}
modalului pe succes (queued/sterge) vine din HX-Trigger `inchideModal` emis de rute. #}

View File

@@ -1,7 +1,7 @@
<div id="import-section"> <div id="import-section">
{% set pas = 1 %}{% include '_stepper.html' %} {% set pas = 1 %}{% include '_stepper.html' %}
{# US-004 (3.6): bara de upload accentuata (border de accent) ca sa ramana punctul {# 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). #} de intrare evident chiar cu tabelul Trimiteri lung dedesubt. #}
{% from '_eroare.html' import card_erori %} {% from '_eroare.html' import card_erori %}
<div class="card" style="border-color:var(--accent);"> <div class="card" style="border-color:var(--accent);">
@@ -105,7 +105,7 @@
var dz = document.getElementById('drop-zone'); var dz = document.getElementById('drop-zone');
var frm = document.getElementById('upload-form'); 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. */ (reset sau dupa commit), sectiunea Trimiteri redevine vizibila. */
var trim = document.getElementById('trimiteri-section'); var trim = document.getElementById('trimiteri-section');
if (trim) trim.style.display = ''; if (trim) trim.style.display = '';

View File

@@ -2,7 +2,7 @@
{% block title %}Conturi clienti — Gateway RAR AUTOPASS{% endblock %} {% block title %}Conturi clienti — Gateway RAR AUTOPASS{% endblock %}
{% block content %} {% 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 = { {% set VERBS = {
'activate': ('Activeaza', '/admin/activate', ''), 'activate': ('Activeaza', '/admin/activate', ''),
'block': ('Blocheaza', '/admin/block', ''), 'block': ('Blocheaza', '/admin/block', ''),

View File

@@ -6,7 +6,7 @@
<title>{% block title %}Gateway RAR AUTOPASS{% endblock %}</title> <title>{% block title %}Gateway RAR AUTOPASS{% endblock %}</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<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, // 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 // 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. // "foster-parent"-eaza div/span-urile OOB afara din fragment -> swapError + contoare pierdute.
@@ -14,12 +14,16 @@
htmx.config.useTemplateFragments = true; htmx.config.useTemplateFragments = true;
</script> </script>
<script> <script>
// Anti-FOUC (US-001 PRD 5.3): citeste preferinta tema din localStorage inainte de // Anti-FOUC: citeste preferinta tema din localStorage inainte de primul
// primul paint; seteaza data-theme pe <html> sincron, fara blink dark->light. // 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() { (function() {
var VALID = {light:1, dark:1, petrol:1, auto:1};
try { try {
var t = localStorage.getItem('theme'); 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'; t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
} }
document.documentElement.setAttribute('data-theme', t); document.documentElement.setAttribute('data-theme', t);
@@ -29,19 +33,100 @@
})(); })();
</script> </script>
<style> <style>
:root { --bg:#0f1115; --card:#181b22; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36; /* IBM Plex Sans + Mono self-hosted (latin-ext pentru diacritice romanesti).
--ok:#3ecf8e; --warn:#e6b34a; --err:#e5605e; --accent:#5b8def; } font-display:swap permite text vizibil inainte de incarcare (FOUT system-ui->IBM Plex);
[data-theme="light"] { --bg:#f6f7f9; --card:#ffffff; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea; reflow-ul vizibil pe VIN/coduri e acceptat explicit. */
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#2563eb; } @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; } * { 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 CSS custom properties NU functioneaza in `@media`, deci pragul nu poate fi o
variabila; folosim consecvent `@media (max-width:767px)` peste tot (mobil) si variabila; folosim consecvent `@media (max-width:767px)` peste tot (mobil) si
`@media (max-width:1024px)` doar pentru densitatea tabelului. >=1024px = layout `@media (max-width:1024px)` doar pentru densitatea tabelului. >=1024px = layout
desktop neschimbat (fara regresie). Orice regula mobila noua reutilizeaza 767px. */ 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; } 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 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; } 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; } 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; } 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; } .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 { 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-queued{color:var(--accent);} .s-sending{color:var(--warn);} .s-sent{color:var(--ok);}
.s-error,.s-needs_data,.s-needs_mapping{color:var(--err);} .s-error,.s-needs_data,.s-needs_mapping{color:var(--err);}
.s-ok{color:var(--ok);} .s-ok{color:var(--ok);}
@@ -94,7 +201,7 @@
button { background:var(--accent); border-color:var(--accent); color:#fff; cursor:pointer; } button { background:var(--accent); border-color:var(--accent); color:#fff; cursor:pointer; }
button:hover { filter:brightness(1.08); } button:hover { filter:brightness(1.08); }
.chk { font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px; } .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; .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; border-bottom:1px solid var(--line); margin-bottom:16px; padding-bottom:0;
scrollbar-width:none; } scrollbar-width:none; }
@@ -108,25 +215,30 @@
border-color:var(--line); border-bottom-color:var(--card); } border-color:var(--line); border-bottom-color:var(--card); }
.tab-panel { min-height:120px; } .tab-panel { min-height:120px; }
.status-bar { margin-bottom:12px; } .status-bar { margin-bottom:12px; }
/* Eroare 3 niveluri (US-006, PRD 5.4) */ /* Eroare 3 niveluri */
.eroare-3n { margin-top:10px; } .eroare-3n { margin-top:10px; }
.eroare-3n-item { padding:8px 10px; border-left:3px solid var(--err); .eroare-3n-item { padding:8px 10px; border-left:3px solid var(--err);
background:color-mix(in srgb, var(--err) 8%, var(--card)); background:color-mix(in srgb, var(--err) 8%, var(--card));
border-radius:0 6px 6px 0; } border-radius:0 6px 6px 0; }
.eroare-3n-sep { margin-top:6px; } .eroare-3n-sep { margin-top:6px; }
.eroare-3n-problema { font-weight:600; color:var(--err); font-size:13px; } .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-cauza { color:var(--muted); font-size:12px; margin-top:3px; }
.eroare-3n-fix { color:var(--accent); font-size:12px; margin-top:3px; } .eroare-3n-fix { color:var(--accent); font-size:12px; margin-top:3px; }
.eroare-3n-label { font-weight:500; } .eroare-3n-label { font-weight:500; }
/* Inline fix per camp in preview */ /* Inline fix per camp in preview */
.camp-fix { color:var(--accent); font-size:11px; margin-top:2px; display:block; } .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; } .cont-menu-wrap { position:relative; }
.icon-btn { background:transparent; border:1px solid var(--line); color:var(--ink); cursor:pointer; .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; 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; } line-height:1; display:inline-flex; align-items:center; justify-content:center; }
.icon-btn:hover { background:var(--line); } .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; .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; 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; } 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; } .cont-menu form { margin:0; }
/* Kebab partajat (actiuni per-rand in tabele). Meniul e position:fixed si pozitionat de JS: /* 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 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 { position:relative; display:inline-block; }
.kebab > summary { list-style:none; cursor:pointer; display:inline-flex; align-items:center; .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; 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); .dt-pager button { background:transparent; color:var(--ink); border:1px solid var(--line);
padding:5px 12px; min-height:32px; } padding:5px 12px; min-height:32px; }
.dt-pager button:disabled { opacity:.45; cursor:default; } .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 .tabel-trimiteri ca sa NU strice celelalte tabele (.tablewrap e partajat de
Mapari/Formate). Permitem wrap controlat pe coloanele text + latimi rezonabile. === */ Mapari/Formate). Permitem wrap controlat pe coloanele text + latimi rezonabile. === */
.tabel-trimiteri table { table-layout:fixed; } .tabel-trimiteri table { table-layout:fixed; }
@@ -181,16 +293,15 @@
.tabel-trimiteri .col-operatie > div { line-height:1.35; } .tabel-trimiteri .col-operatie > div { line-height:1.35; }
/* secundarul muted („cod RAR" / „nemapat") — >=12px, contrast pe var(--muted) >=4.5:1 */ /* secundarul muted („cod RAR" / „nemapat") — >=12px, contrast pe var(--muted) >=4.5:1 */
.tabel-trimiteri .cod-rar-sub { font-size:12px; margin-top:2px; } .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:". */ /* 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; .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); font-size:12px; padding:1px 7px; border:1px solid var(--line);
border-radius:99px; color:var(--muted); } border-radius:99px; color:var(--muted); }
/* PRD 5.9 US-002 (R1): eticheta umana scurta sub pill — text mic; clasa `s-error` /* Eticheta umana scurta sub pill — text mic; clasa `s-error` o coloreaza
o coloreaza (apare doar pe error/needs_*). Stare prin text, nu doar culoare. */ (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; } .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 /* Randul e clickabil (deschide modalul) -> tinta de atins >=44px (touch) +
(touch) + afordanta hover/focus. Inlocuieste vechea regula `@media pointer:coarse afordanta hover/focus. */
.chevron` (chevron eliminat); este SINGURA regula 44px pe rand. */
.tabel-trimiteri tr.trimitere-row { min-height:44px; } .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 > td { padding-top:11px; padding-bottom:11px; }
.tabel-trimiteri tr.trimitere-row:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); } .tabel-trimiteri tr.trimitere-row:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
@@ -200,10 +311,10 @@
@media (max-width:1024px) { @media (max-width:1024px) {
.tabel-trimiteri .col-actualizat { display:none; } .tabel-trimiteri .col-actualizat { display:none; }
} }
/* === Modal detaliu (PRD 5.9 US-003): fereastra modala globala, in afara zonei de /* === Modal detaliu: fereastra modala globala, in afara zonei de poll
poll (#submissions-wrap). Backdrop + dialog centrat pe desktop; focus-trap + (#submissions-wrap). Backdrop + dialog centrat pe desktop; focus-trap +
scroll-lock + inert pe <main> sunt in JS. Varianta full-screen mobil: vezi blocul scroll-lock + inert pe <main> sunt in JS. Varianta full-screen mobil: vezi blocul
`@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; .modal-overlay { position:fixed; inset:0; z-index:1100; display:flex;
align-items:flex-start; justify-content:center; padding:40px 16px; overflow-y:auto; } align-items:flex-start; justify-content:center; padding:40px 16px; overflow-y:auto; }
.modal-overlay[hidden] { display:none; } .modal-overlay[hidden] { display:none; }
@@ -220,9 +331,9 @@
body.modal-open { overflow:hidden; } body.modal-open { overflow:hidden; }
.modal-eroare { padding:16px 4px; } .modal-eroare { padding:16px 4px; }
.modal-eroare .actiuni { margin-top:12px; display:flex; gap:10px; flex-wrap:wrap; } .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 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. */ >=44px. Desktop (>=1024px) ramane neschimbat — regulile de baza nu se modifica. */
@media (max-width:767px) { @media (max-width:767px) {
/* Tabel trimiteri: card per rand (eticheta:valoare stivuit) -> fara scroll orizontal */ /* Tabel trimiteri: card per rand (eticheta:valoare stivuit) -> fara scroll orizontal */
@@ -245,21 +356,26 @@
padding:16px; padding-top:56px; overflow-y:auto; } padding:16px; padding-top:56px; overflow-y:auto; }
.modal-close { width:44px; height:44px; top:8px; right:8px; font-size:24px; } .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%; } .detaliu-actiuni-jos button { width:100%; }
/* Header + nav colapsate: header se rupe pe linii, fara scroll orizontal de pagina; /* Header + nav colapsate: pe mobil trece de la grid la flex wrap.
tintele touch (toggle tema/cont, taburi, itemi meniu cont) cresc la >=44px. */ Randul 1: [logo ROMFAST stanga] [controale dreapta] (margin-left:auto pe .header-right).
header { padding:12px 16px; flex-wrap:wrap; gap:8px; } 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; } header h1 { font-size:17px; }
main { padding:16px; } main { padding:16px; }
.icon-btn { min-height:44px; min-width:44px; } .icon-btn { min-height:44px; min-width:44px; }
.tab-link { min-height:44px; padding:10px 14px; } .tab-link { min-height:44px; padding:10px 14px; }
.cont-menu a, .cont-menu button { min-height:44px; } .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`, 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 trimiteri. Tabele DENSE read-only (Jurnal, Nomenclator) + Admin raman in
`.tablewrap` (scroll orizontal CONTAINED, definit global mai sus). */ `.tablewrap` (scroll orizontal CONTAINED, definit global mai sus). */
.tabel-card table { table-layout:auto; } .tabel-card table { table-layout:auto; }
@@ -291,11 +407,11 @@
#card-cont button, #form-test-cheie button, #card-cont button, #form-test-cheie button,
#jurnal-section #filtre-jurnal button { min-height:44px; width:100%; } #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 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 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), id-urile sectiunilor de pe Acasa ca sa NU atinga tabelul de trimiteri,
modalul sau paginile de continut (US-007). */ modalul sau paginile de continut. */
/* Bara de upload: zona slim (returning user) trece pe coloana; butonul full-width. */ /* 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 .drop-zone { flex-direction:column; align-items:stretch; text-align:left; }
#import-section #upload-btn { width:100%; min-height:44px; } #import-section #upload-btn { width:100%; min-height:44px; }
@@ -315,22 +431,36 @@
</style> </style>
</head> </head>
<body> <body>
{# Grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). #}
<header> <header>
<h1>Gateway RAR AUTOPASS</h1> {# Celula stanga: logo ROMFAST #}
<span class="env">{{ rar_env }}</span> <div class="header-left">
<div style="margin-left:auto; display:flex; align-items:center; gap:8px;"> {# 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" <button id="tema-toggle" class="icon-btn"
aria-label="Comuta tema (luminos/intunecat)" aria-label="Comuta tema (luminos/intunecat)"
title="Comuta tema">&#9728;</button> title="Comuta tema">&#9728;</button>
<span class="muted" style="font-size:13px;">v{{ version }}</span> <span class="muted" style="font-size:13px;">v{{ version }}</span>
{% if is_authenticated|default(false) %} {% 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. #} Pe paginile neautentificate (login/signup) nu se randeaza deloc. #}
<div class="cont-menu-wrap"> <div class="cont-menu-wrap">
<button id="cont-menu-toggle" class="icon-btn" <button id="cont-menu-toggle" class="icon-btn"
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu" aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
aria-label="Meniu cont" title="Meniu cont">&#9776;</button> aria-label="Meniu cont" title="Meniu cont">&#9776;</button>
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden> <div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden>
{# 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=cont">Cont</a>
<a role="menuitem" href="/?tab=integrare">Integrare</a> <a role="menuitem" href="/?tab=integrare">Integrare</a>
<a role="menuitem" href="/?tab=nomenclator">Nomenclator</a> <a role="menuitem" href="/?tab=nomenclator">Nomenclator</a>
@@ -346,11 +476,14 @@
{% endif %} {% endif %}
</div> </div>
</header> </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> <main>{% block content %}{% endblock %}</main>
{# Modal detaliu trimitere (PRD 5.9 US-003): container global, SIBLING al <main> {# Modal detaliu trimitere: container global, SIBLING al <main> (nu descendent),
(nu descendent), ca `inert`+`aria-hidden` pe <main> sa nu-l prinda si pe el (R7). ca `inert`+`aria-hidden` pe <main> sa nu-l prinda si pe el. Corpul
Corpul #detaliu-modal-body e tinta de swap pentru fragment + rutele corectie/ #detaliu-modal-body e tinta de swap pentru fragment + rutele corectie/mapare/
mapare/lifecycle. Traieste in afara #submissions-wrap -> poll-ul de 15s nu-l atinge. #} lifecycle. Traieste in afara #submissions-wrap -> poll-ul nu-l atinge. #}
<div id="modal-detaliu" class="modal-overlay" role="dialog" aria-modal="true" <div id="modal-detaliu" class="modal-overlay" role="dialog" aria-modal="true"
aria-labelledby="detaliu-modal-titlu" hidden> aria-labelledby="detaliu-modal-titlu" hidden>
<div class="modal-backdrop" data-modal-close></div> <div class="modal-backdrop" data-modal-close></div>
@@ -360,41 +493,49 @@
</div> </div>
</div> </div>
<script> <script>
// Handler comutator tema (US-002 PRD 5.3): click toggle light<->dark, persista in localStorage. // Comutator tema ciclic: click cicleaza Light->Dark->Petrol->Auto.
// Separare init (doar sincronizare iconita) de persistenta (doar la click explicit). // Separare init (sincronizare iconita/label) de persistenta (doar la click explicit).
// Motivatie: scrierea in localStorage la init ar ingloba imediat preferinta OS-aware ca alegere // 'auto' se rezolva la paint prin anti-FOUC; aici setam data-theme rezolvat.
// explicita, impiedicand urmarirea ulterioara a modificarilor de tema ale sistemului de operare.
(function() { (function() {
var btn = document.getElementById('tema-toggle'); var btn = document.getElementById('tema-toggle');
if (!btn) return; if (!btn) return;
// Sincronizeaza iconita si aria-label dupa tema curenta -- FARA efecte secundare in localStorage. var CYCLE = ['light', 'dark', 'petrol', 'auto'];
function _syncIcon(t) { var VALID = {light:1, dark:1, petrol:1, auto:1};
if (t === 'light') { // Iconite per tema: ☀ Light, ☾ Dark, ◐ Petrol, ◉ Auto
btn.innerHTML = '&#9790;'; var ICONS = {light:'&#9728;', dark:'&#9790;', petrol:'&#9680;', auto:'&#9689;'};
btn.setAttribute('aria-label', 'Comuta tema (intunecat)'); var LABELS = {light:'Light', dark:'Dark', petrol:'Petrol', auto:'Auto'};
btn.title = 'Comuta tema (intunecat)'; var NEXT = {light:'Dark', dark:'Petrol', petrol:'Auto', auto:'Light'};
} else { function _stored() {
btn.innerHTML = '&#9728;'; try { var v = localStorage.getItem('theme'); return (v && VALID[v]) ? v : 'auto'; } catch(e) { return 'auto'; }
btn.setAttribute('aria-label', 'Comuta tema (luminos)'); }
btn.title = 'Comuta tema (luminos)'; 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) { function _setTheme(t) {
document.documentElement.setAttribute('data-theme', t); document.documentElement.setAttribute('data-theme', _resolved(t));
try { localStorage.setItem('theme', t); } catch(e) {} 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). // Init: sincronizeaza iconita din starea stocata (fara a scrie in localStorage).
_syncIcon(document.documentElement.getAttribute('data-theme') || 'dark'); _syncButton(_stored());
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
var cur = document.documentElement.getAttribute('data-theme') || 'dark'; var cur = _stored();
_setTheme(cur === 'dark' ? 'light' : 'dark'); var idx = CYCLE.indexOf(cur);
_setTheme(CYCLE[(idx + 1) % CYCLE.length]);
}); });
})(); })();
</script> </script>
<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. // inchide la Esc (focus readus pe buton) si la click in afara. Fara dependente.
(function() { (function() {
var toggle = document.getElementById('cont-menu-toggle'); var toggle = document.getElementById('cont-menu-toggle');
@@ -470,6 +611,19 @@
window.addEventListener('resize', function() { closeAll(null); }); window.addEventListener('resize', function() { closeAll(null); });
})(); })();
</script> </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> <script>
// Cautare + paginare client-side pentru tabele mari (data-dt="<page_size>"). Filtreaza si // 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 // pagineaza DOM-ul deja randat (fara cereri server) — potrivit pentru maparile care pot creste
@@ -534,11 +688,11 @@
})(); })();
</script> </script>
<script> <script>
// Modal detaliu trimitere (PRD 5.9 US-003): inlocuieste detaliul inline (5.8). Detaliul // Modal detaliu trimitere: detaliul se incarca prin HTMX in #detaliu-modal-body
// se incarca prin HTMX in #detaliu-modal-body (in afara #submissions-wrap, deci poll-ul // (in afara #submissions-wrap, deci poll-ul nu-l atinge). Aici: deschidere la click
// de 15s nu-l atinge). Aici: deschidere la click pe rand, inchidere (x/Esc/backdrop), // pe rand, inchidere (x/Esc/backdrop), focus-trap, scroll-lock, inert+aria-hidden pe
// focus-trap, scroll-lock, inert+aria-hidden pe <main> (R7), stare de eroare la load // <main>, stare de eroare la load esuat, inchidere pe succes corectie/sterge
// esuat (R5), inchidere pe succes corectie/sterge (HX-Trigger inchideModal, R5). // (HX-Trigger inchideModal).
(function() { (function() {
var overlay = document.getElementById('modal-detaliu'); var overlay = document.getElementById('modal-detaliu');
if (!overlay) return; if (!overlay) return;
@@ -555,7 +709,7 @@
' select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'), ' select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'),
function(el) { return el.offsetParent !== null || el === document.activeElement; }); 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) { function trapFocus(e) {
if (e.key !== 'Tab') return; if (e.key !== 'Tab') return;
var f = focusable(); var f = focusable();
@@ -589,7 +743,6 @@
if (t && t.focus) t.focus(); // focus readus pe rand if (t && t.focus) t.focus(); // focus readus pe rand
} }
// API public: butonul „Inchide" din fragment + inchiderea pe succes corectie/sterge. // 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(); }; window.inchideDetaliu = function() { close(); };
// Inchidere: x si backdrop (elemente cu data-modal-close), Esc. // Inchidere: x si backdrop (elemente cu data-modal-close), Esc.
@@ -612,7 +765,7 @@
var f = focusable(); var f = focusable();
if (f.length) f[0].focus(); 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) { body.addEventListener('htmx:responseError', function(evt) {
if (!isOpen()) return; if (!isOpen()) return;
var elt = evt.detail && evt.detail.elt; var elt = evt.detail && evt.detail.elt;
@@ -630,7 +783,7 @@
{ target: body, swap: 'innerHTML' }); { 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 // Lista se reincarca separat prin `trimiteriChanged` (#submissions-wrap). Maparea
// inline NU emite inchideModal -> modalul ramane deschis sa arate codul rezolvat. // inline NU emite inchideModal -> modalul ramane deschis sa arate codul rezolvat.
document.body.addEventListener('inchideModal', function() { close(); }); document.body.addEventListener('inchideModal', function() { close(); });
@@ -647,45 +800,49 @@
})(); })();
</script> </script>
<script> <script>
// Poll-guard (PRD 5.9 US-005, R6). Inlocuieste vechea pauza pe „rand expandat" (5.8): // Filtrare stare prin pill-uri + reincarcare manuala a tabelului. Tabelul NU se mai
// randul-sibling de detaliu nu mai exista (US-003 l-a mutat in modalul global, care // schimba singur (fara poll periodic pe #submissions-wrap): un poller usor verifica
// traieste in afara #submissions-wrap -> un swap de poll nu-l atinge). Aici oprim // doar versiunea datelor si arata nudge-ul "Date noi" cand difera. Reincarcarea
// poll-ul de 15s de a REINCARCA lista cat timp (a) modalul e deschis SAU (b) exista // (pill, nudge sau actiune) trece prin form -> pastreaza filtrul/pagina curenta.
// 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.
(function() { (function() {
function modalDeschis() { // Pill de stare: scrie campul hidden, reseteaza pagina la 1 si re-trimite filtrul.
var o = document.getElementById('modal-detaliu'); window.filtreazaStare = function(btn, status) {
return !!(o && !o.hidden); 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() { function verifica() {
return !!document.querySelector('#submissions-wrap input[name="submission_id"]:checked'); 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) { setInterval(verifica, INTERVAL);
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');
}
});
})(); })();
</script> </script>
</body> </body>

View File

@@ -1,10 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
{# US-007 (5.5): nav-ul ad-hoc (Panou admin + logout) a fost mutat in meniul de cont (☰) <!-- Bara de status: mereu vizibila -->
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 -->
<div id="status-bar" class="status-bar card" <div id="status-bar" class="status-bar card"
hx-get="/_fragments/status" hx-get="/_fragments/status"
hx-trigger="load, every 15s" hx-trigger="load, every 15s"
@@ -12,80 +9,9 @@
<div class="empty muted" style="padding:8px 0;">se incarca starea…</div> <div class="empty muted" style="padding:8px 0;">se incarca starea…</div>
</div> </div>
<!-- Tab-bar: navigare intre sectiuni --> <!-- Panou activ: randat server-side la full load (Acasa implicit, sau ?tab= prin meniu) -->
<div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard"> <div id="tab-panel" class="tab-panel">
{# 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">
{{ panel_html | safe }} {{ panel_html | safe }}
</div> </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 %} {% 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. Bucla: heartbeat -> recupereaza orfane -> claim atomic -> login -> postPrezentare -> update.
Ruleaza ca proces separat sub `restart: always` (docker compose). Ruleaza ca proces separat sub `restart: always` (docker compose).
T2 implementat:
- claim atomic anti-race (BEGIN IMMEDIATE), respecta next_attempt_at (backoff). - claim atomic anti-race (BEGIN IMMEDIATE), respecta next_attempt_at (backoff).
- reconciliere anti-duplicat pe raspuns pierdut: pe eroare tranzitorie/timeout SAU pe - reconciliere anti-duplicat pe raspuns pierdut: pe eroare tranzitorie/timeout SAU pe
rand 'sending' orfan (worker mort mid-POST), interogheaza finalizate si match pe rand 'sending' orfan (worker mort mid-POST), interogheaza finalizate si match pe
vin+dataPrestatie+odometruFinal; daca exista -> 'sent' (NU re-trimite). 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. - lease/timeout pe randuri 'sending' orfane.
- re-login la token expirat (401 mid-sesiune) — JWT 30h, retry NU plafonat la 30h. - 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 Creds per-cerere: fiecare submission poarta creds RAR CRIPTATE (rar_creds_enc).
(rar_creds_enc). Worker-ul face login per CONT cu acele creds, cache-uieste JWT Worker-ul face login per CONT cu acele creds, cache-uieste JWT (30h) in memorie si
(30h) in memorie si STERGE creds-urile contului dupa primul login reusit. Token-ul STERGE creds-urile contului dupa primul login reusit. Token-ul in memorie acopera
in memorie acopera restul trimiterilor; la restart token-ul se pierde si contul restul trimiterilor; la restart token-ul se pierde si contul re-logheaza la urmatorul
re-loghează la urmatorul submission care aduce creds proaspete (degradare acceptata). submission care aduce creds proaspete (degradare acceptata).
Dev: `worker_use_test_creds` foloseste creds <test> cand submission-ul nu are enc. 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 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: 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 """Emite evenimentul (sursa=worker, dublu canal DB+fisier) SI pastreaza linia in
canal DB+fisier) SI pastreaza linia in stdout (operatorul tailuieste .run/worker.log).""" stdout (operatorul tailuieste .run/worker.log)."""
print(f"[worker] {mesaj}", flush=True) print(f"[worker] {mesaj}", flush=True)
log_event(tip, nivel=nivel, account_id=account_id, cod=cod, mesaj=mesaj, context=context, log_event(tip, nivel=nivel, account_id=account_id, cod=cod, mesaj=mesaj, context=context,
conn=conn, sursa="worker") conn=conn, sursa="worker")
@@ -84,17 +81,17 @@ def _is_transient(exc: Exception) -> bool:
# --- Operatii pe submissions --- # --- Operatii pe submissions ---
# Stari blocate ne-sent care primesc retentie proprie (US-013). Mai scurta decat # Stari blocate ne-sent care primesc retentie proprie. Mai scurta decat cele 90z
# cele 90z ale `sent`: un blocat n-are valoare de audit ca o trimitere reusita. # ale `sent`: un blocat n-are valoare de audit ca o trimitere reusita.
_BLOCKED_STATES = ("error", "needs_data", "needs_mapping") _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: def mark(conn, submission_id: int, status: str, *, rar_status_code=None, rar_error=None, id_prezentare=None) -> None:
if status == "sent": 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')" purge_expr = "datetime('now', '+90 days')"
elif status in _BLOCKED_STATES: 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) days = int(get_settings().blocked_retention_days)
purge_expr = f"datetime('now', '+{days} days')" purge_expr = f"datetime('now', '+{days} days')"
else: 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 _PURGE_INTERVAL_S = 3600
def purge_expired(conn) -> dict[str, int]: def purge_expired(conn) -> dict[str, int]:
"""Sterge randurile expirate (purge_after < now). """Sterge randurile expirate (purge_after < now).
T16/OV-5 + US-013/US-008: submissions `sent` SI blocate (error/needs_data/needs_mapping) Submissions `sent` SI blocate (error/needs_data/needs_mapping) expirate;
expirate; import_batches expirate (import_rows via CASCADE); app_events expirate (jurnal). import_batches expirate (import_rows via CASCADE); app_events expirate (jurnal).
EXCLUDE explicit `queued`/`sending` (randuri active — nu se purjeaza niciodata, chiar EXCLUDE explicit `queued`/`sending` (randuri active — nu se purjeaza niciodata, chiar
daca ar avea un purge_after rezidual; reactivarea il curata oricum). daca ar avea un purge_after rezidual; reactivarea il curata oricum).
Intoarce {submissions_purged, batches_purged, events_purged}. 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 " "FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
"WHERE s.status='queued' " "WHERE s.status='queued' "
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) " "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'. # 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' " "AND COALESCE(a.status, CASE WHEN COALESCE(a.active,1)=1 THEN 'active' ELSE 'pending' END) = 'active' "
"ORDER BY s.id LIMIT 1", "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 # 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, # pierdere de raspuns ambigua -> NU reconcilia (recordul, daca exista la RAR,
# e PARTIAL/rupt si nu trebuie marcat fals 'sent') si NU reincerca (acelasi # 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) 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) mark(conn, sid, "error", rar_status_code=500, rar_error=detail)
_wlog(conn, "submission_error", f"submission {sid} -> error (RAR 500): {exc.rar_message}", _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"]) token = rar.login(creds["email"], creds["password"])
except RarAuthError as exc: except RarAuthError as exc:
rar.close() 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, log_event("rar_login", nivel="WARNING", account_id=account_id,
cod="RAR_CREDS_INVALIDE", cod="RAR_CREDS_INVALIDE",
mesaj=f"login RAR esuat (cont {account_id}): {exc.status_code or 401}", mesaj=f"login RAR esuat (cont {account_id}): {exc.status_code or 401}",
@@ -375,11 +372,11 @@ class AccountSessions:
raise raise
self._sessions[account_id] = (rar, token) self._sessions[account_id] = (rar, token)
write_heartbeat(conn, rar_login_ok=True, detail=f"login RAR ok (cont {account_id})") 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})", 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") context={"rezultat": "ok", "http": 200}, conn=conn, sursa="worker")
# Creds efemere pe submissions nu mai sunt necesare: JWT acopera retry-urile -> sterge. # 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 web: fallback exista in accounts -> purjarea e inofensiva (re-login dupa restart).
# Canal API pur: purjarea e identica cu Treapta 1 (neatinsa). # Canal API pur: purjarea e identica cu Treapta 1 (neatinsa).
conn.execute( 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: 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 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. 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) signal.signal(signal.SIGINT, _stop)
settings = get_settings() 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() init_db()
conn = get_connection() conn = get_connection()
print(f"[worker] pornit (send_enabled={settings.worker_send_enabled}, env={settings.rar_env})", flush=True) print(f"[worker] pornit (send_enabled={settings.worker_send_enabled}, env={settings.rar_env})", flush=True)
@@ -448,7 +445,7 @@ def run() -> int:
try: try:
write_heartbeat(conn, detail=f"poll (queue={_queue_depth(conn)})") 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() now_ts = time.time()
if now_ts - _last_purge_time >= _PURGE_INTERVAL_S: if now_ts - _last_purge_time >= _PURGE_INTERVAL_S:
stats = purge_expired(conn) stats = purge_expired(conn)
@@ -474,20 +471,20 @@ def run() -> int:
sid = claimed["id"] sid = claimed["id"]
account_id = claimed["account_id"] account_id = claimed["account_id"]
# T1/US-012: randul poarta creds proaspete (rar_creds_enc != NULL) — fie prima # Randul poarta creds proaspete (rar_creds_enc != NULL) — fie prima trimitere
# trimitere a contului, fie o REACTIVARE dupa creds gresite. Invalidam sesiunea # a contului, fie o REACTIVARE dupa creds gresite. Invalidam sesiunea RAR
# RAR cache-uita ca un JWT vechi (30h) din parola GRESITA sa nu trimita cu ea, # cache-uita ca un JWT vechi (30h) din parola GRESITA sa nu trimita cu ea,
# ignorand corectia. Re-login imediat cu creds-urile noi. # ignorand corectia. Re-login imediat cu creds-urile noi.
if claimed.get("creds_enc"): if claimed.get("creds_enc"):
sessions.invalidate(account_id) 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. # 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) creds = _creds_for(claimed, settings) or _creds_from_account(conn, account_id)
try: try:
token = sessions.get_token(conn, account_id, creds) token = sessions.get_token(conn, account_id, creds)
except RarAuthError as exc: 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, 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_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. # rar_login esuat e deja logat in get_token; aici doar tranzitia submission-ului.

View File

@@ -9,13 +9,11 @@ services:
api: api:
build: . build: .
command: uvicorn app.main:app --host 0.0.0.0 --port 8010 command: uvicorn app.main:app --host 0.0.0.0 --port 8010
ports:
- "8010:8010"
volumes: volumes:
- autopass-data:/data - autopass-data:/data
environment: environment:
AUTOPASS_DB_PATH: /data/autopass.db 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_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
AUTOPASS_REQUIRE_API_KEY: ${AUTOPASS_REQUIRE_API_KEY:-false} AUTOPASS_REQUIRE_API_KEY: ${AUTOPASS_REQUIRE_API_KEY:-false}
restart: always restart: always
@@ -35,7 +33,7 @@ services:
AUTOPASS_RAR_ENV: test AUTOPASS_RAR_ENV: test
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)} 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. # Send dezactivat by default; activeaza pentru proba end-to-end.
AUTOPASS_WORKER_SEND_ENABLED: "false" AUTOPASS_WORKER_SEND_ENABLED: "true"
restart: always restart: always
depends_on: depends_on:
- api - 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): 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("/") r = client.get("/")
assert r.status_code == 200 assert r.status_code == 200
html = r.text html = r.text
# "Coada" nu trebuie sa existe nici ca tab, nici ca link in meniu
assert 'id="tab-coada"' not in html assert 'id="tab-coada"' not in html
assert 'href="/?tab=coada"' not in html assert 'href="/?tab=coada"' not in html
for label in ("Acasa", "Mapari", "Cont", "Nomenclator"): # US-009: tab-bar eliminat; Mapari/Cont/Nomenclator sunt in meniul hamburger
assert f">{label}" in html or f"{label}<" in html, f"lipseste tab {label}" 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): 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] assert "2" in html[idx:idx + 400]
def test_trimiteri_poll_aliniat_15s(client): def test_trimiteri_fara_poll_periodic_pe_tabel(client):
"""Poll-ul de trimiteri e aliniat la 15s (anti dublu-poll M5), nu 10s.""" """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") _seed_submission("sent")
r = client.get("/?tab=acasa") r = client.get("/?tab=acasa")
html = r.text html = r.text
assert "every 15s" in html # Trigger-ul tabelului nu contine poll periodic.
assert "every 10s" not 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}"
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["operatie"] == "Verificare"
assert d3["cod"] == "oe-2" # cod ramas neschimbat (nu uppercase) 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): 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") resp = client.get("/login")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
assert "--bg:#0f1115" in html, "Paleta dark --bg:#0f1115 a fost modificata sau stearsa" assert "--bg:#0f1218" in html, "Paleta dark --bg:#0f1218 a fost modificata sau stearsa"
assert "--card:#181b22" in html, "Paleta dark --card:#181b22 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" 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): 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") acct = _create_account_user("bm@test.com")
_ins(acct, "needs_mapping") _ins(acct, "needs_mapping")
_ins(acct, "needs_mapping") _ins(acct, "needs_mapping")
@@ -82,10 +82,14 @@ def test_badge_mapari(client):
resp = client.get("/") resp = client.get("/")
assert resp.status_code == 200 assert resp.status_code == 200
link = _tab_link(resp.text, "tab-mapari") html = resp.text
assert "tab-badge" in link # US-009: Mapari e acum in meniu (nu tab); badgeul apare in intrarea meniului
assert "2" in link idx = html.find('href="/?tab=mapari"')
assert "necesita atentie" in link # aria-label 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): def test_badge_trimiteri_blocate(client):

View File

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

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. # Exista un bloc media mobil care vizeaza modalul.
assert "@media (max-width:767px)" in html 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). # Dialogul ocupa tot ecranul pe mobil (latime/inaltime pline, fara border-radius lateral).
mobil = html[html.find("@media (max-width:767px)"):] mobil = html[html.find("@media (max-width:767px)"):]
assert "100vw" in mobil or "width:100%" in mobil 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 assert 'data-eticheta="Daca operatia contine"' in mapari
# Regula de card e definita o data in base.html, scopata pe `.tabel-card`. # Regula de card e definita o data in base.html, scopata pe `.tabel-card`.
assert ".tabel-card thead" in mapari assert ".tabel-card thead" in mapari
assert "US-007" in mapari
# --- Jurnal = scroll contained. Cu un eveniment seedat, tabelul apare in `.tablewrap`, # --- Jurnal = scroll contained. Cu un eveniment seedat, tabelul apare in `.tablewrap`,
# NU ca `.tabel-card`. --- # NU ca `.tabel-card`. ---
@@ -259,7 +256,6 @@ def test_acasa_fara_scroll_orizontal_mobil(client):
assert 'id="import-section"' in html assert 'id="import-section"' in html
assert 'id="status-bar"' in html assert 'id="status-bar"' in html
assert 'id="filtre-trimiteri"' in html assert 'id="filtre-trimiteri"' in html
assert "US-008" in html
mobil = html[html.find("@media (max-width:767px)"):] 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("needs_data", acct_id)
_insert_submission("error", 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 assert resp.status_code == 200
html = resp.text html = resp.text
# Trebuie sa arate titlul grupului de blocate # Pill-urile au etichetele scurte per categorie (nu etichetele lungi din eticheta_stare)
assert "Necesita atentia ta" in html, ( assert "Lipsa cod" in html, "Acasa nu arata pill-ul pentru needs_mapping"
f"Fragmentul nu contine 'Necesita atentia ta'. HTML: {html[:800]}" assert "Date incomplete" in html, "Acasa nu arata pill-ul pentru needs_data"
) assert "Eroare" in html, "Acasa nu arata pill-ul pentru error"
# Trebuie sa arate etichetele umane pe motiv (din STARI_SUBMISSION in labels.py) # Pill-urile arata numarul total per categorie (2 needs_mapping, 1 needs_data, 1 error)
assert "Lipseste codul prestatiei" in html, ( assert 'class="pill-cat"' in html, "Pill-urile trebuie sa fie elemente cu clasa pill-cat"
"Fragmentul nu arata eticheta pentru needs_mapping" assert "<button" in html, "Pill-urile trebuie sa fie elemente <button>"
)
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"
# ============================================================ # ============================================================
@@ -208,26 +197,30 @@ def _insert_submission_vehicul(status, account_id, vin, nr):
def test_categorie_blocata_linkeaza_la_trimiteri_filtrate(client): 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") acct_id, _ = _create_account_user("link@test.com", "parolasecreta10")
_login(client, "link@test.com", "parolasecreta10") _login(client, "link@test.com", "parolasecreta10")
_insert_submission("error", acct_id) _insert_submission("error", acct_id)
html = client.get("/_fragments/status").text html = client.get("/?tab=acasa").text
# Link HTMX catre lista filtrata pe error + deep-link server-side # Pill-ul scrie campul de filtru si re-trimite form-ul (nu mai navigheaza prin deep-link)
assert "/_fragments/submissions?status=error" in html assert "filtreazaStare(this, 'error')" in html
assert "tab=acasa&status=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") acct_id, _ = _create_account_user("ident@test.com", "parolasecreta10")
_login(client, "ident@test.com", "parolasecreta10") _login(client, "ident@test.com", "parolasecreta10")
_insert_submission_vehicul("error", acct_id, "WVWZZZ1KZAW000123", "B123ABC") _insert_submission_vehicul("error", acct_id, "WVWZZZ1KZAW000123", "B123ABC")
html = client.get("/_fragments/status").text html = client.get("/_fragments/status").text
# VIN partial (ultimele 4) + nr inmatriculare + #id # Bara de status arata doar contoare, nu lista cu VIN/nr per rand (fara PII nominal)
assert "0123" in html, "lipseste VIN partial" assert "B123ABC" not in html, "Nr inmatriculare nu trebuie sa mai apara in bara de status"
assert "B123ABC" in html, "lipseste nr inmatriculare"
assert "WVWZZZ1KZAW000123" not in html, "VIN integral nu trebuie expus" 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): 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): 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") _create_account_user("tabbar@test.com", "parolasecreta10")
_login(client, "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 assert resp.status_code == 200
html = resp.text html = resp.text
assert 'role="tablist"' in html, "Lipseste role=tablist" # 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)"
# Doar Acasa + Mapari sunt tab-uri (role="tab") # Cont/Integrare/Nomenclator raman in meniu, nu ca tab-uri
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
for label in ("Cont", "Integrare", "Nomenclator", "Import"): for label in ("Cont", "Integrare", "Nomenclator", "Import"):
assert not re.search(rf'role="tab"[^>]*>\s*{label}\s*<', html), \ 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)" f"'{label}' nu ar mai trebui sa fie un tab separat"
# ...dar traiesc in meniul de cont # 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 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): 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") _create_account_user("implicit@test.com", "parolasecreta10")
_login(client, "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 assert resp.status_code == 200
html = resp.text html = resp.text
# Tab-ul activ trebuie sa aiba aria-selected="true" # US-009: tab-bar eliminat, deci nu mai exista aria-selected pe tab-uri
assert 'aria-selected="true"' in html, "Lipseste aria-selected=true pe tab-ul activ" 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
# Verificam ca Acasa e cel cu aria-selected=true assert 'id="status-bar"' in html, "Status-bar-ul trebuie sa fie prezent"
# Cautam un fragment care contine atat Acasa cat si aria-selected="true" in proximitate assert 'id="tab-panel"' in html, "Panoul de continut (tab-panel) trebuie sa fie prezent"
assert re.search(r'aria-selected="true"[^>]*>.*?Acasa|Acasa.*?aria-selected="true"', html, re.DOTALL), \
"Tab-ul Acasa nu are aria-selected=true"
# ============================================================ # ============================================================
@@ -140,23 +137,22 @@ def test_deeplink_tab_import(client):
# ============================================================ # ============================================================
def test_tab_activ_randat_server_side(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") _create_account_user("serverside@test.com", "parolasecreta10")
_login(client, "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("/") resp = client.get("/")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# Panoul trebuie sa aiba role="tabpanel" # US-009: role="tabpanel" eliminat; continutul e in div#tab-panel fara rol ARIA de tabpanel
assert 'role="tabpanel"' in html, "Lipseste role=tabpanel in HTML initial" 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") resp2 = client.get("/?tab=import")
assert resp2.status_code == 200 assert resp2.status_code == 200
html2 = resp2.text 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" 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): 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") _create_account_user("aria@test.com", "parolasecreta10")
_login(client, "aria@test.com", "parolasecreta10") _login(client, "aria@test.com", "parolasecreta10")
@@ -213,11 +209,14 @@ def test_tabbar_aria(client):
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
assert 'role="tablist"' in html, "Lipseste role=tablist" # US-009: un role="tablist" cu un singur tab e violare ARIA → eliminat
assert 'role="tab"' in html, "Lipseste role=tab" assert 'role="tablist"' not in html, "role=tablist trebuie eliminat (US-009)"
assert 'role="tabpanel"' in html, "Lipseste role=tabpanel" assert 'role="tab"' not in html, "role=tab trebuie eliminat (tab-bar eliminat)"
assert 'aria-selected="true"' in html, "Lipseste aria-selected=true pe tab-ul activ" assert 'role="tabpanel"' not in html, "role=tabpanel trebuie eliminat (tab-bar eliminat)"
assert 'aria-selected="false"' in html, "Lipseste aria-selected=false pe tab-urile inactive" 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") resp = client.get("/_fragments/mapari")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# panou Ajutor (<details>) prezent # US-010: sectiunea de ajutor (<details class="ajutor-mapari">) eliminata
assert "ajutor-mapari" in html assert "ajutor-mapari" not in html
assert "<details" in html and ">Ajutor<" in html
# antet de coloana compact # antet de coloana compact
assert ">In coada<" in html assert ">In coada<" in html
# proza inline veche eliminata de pe sectiuni # proza inline veche eliminata de pe sectiuni