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>
This commit is contained in:
Claude Agent
2026-06-25 21:44:24 +00:00
parent f05fe5b221
commit 4a2afc68bf
43 changed files with 547 additions and 649 deletions

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,10 +115,10 @@ 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"))
# US-002: operatia de service originala (codul intern + denumire venita prin API/import), # Operatia de service originala (codul intern + denumire venita prin API/import),
# distincta de operatia RAR mapata (cod_rar). # distincta de operatia RAR mapata (cod_rar).
# Conventie goala: aceste campuri NOI intorc "" (string gol) cand lipsesc — NU EMPTY="—". # Conventie goala: aceste campuri intorc "" (string gol) cand lipsesc — NU EMPTY="—".
# Motivul: US-007 decide sa nu afiseze randul deloc (vs afisaj gol), testând `!= ""`. # Motivul: apelantul decide sa nu afiseze randul deloc (vs afisaj gol), testând `!= ""`.
# Campurile vechi (vehicul_nr, vin, operatie etc.) pastreaza conventia EMPTY="—". # Campurile vechi (vehicul_nr, vin, operatie etc.) pastreaza conventia EMPTY="—".
op_service_cod = _clean_str(item.get("cod_op_service")) op_service_cod = _clean_str(item.get("cod_op_service"))
# op_service_denumire e relevant doar cand exista un cod de operatie de service; # op_service_denumire e relevant doar cand exista un cod de operatie de service;
@@ -134,7 +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,
# US-002: chei noi cu conventie goala "" (nu EMPTY) — vezi comentariu de mai sus # Chei cu conventie goala "" (nu EMPTY) — vezi comentariu de mai sus
"op_service_cod": op_service_cod, "op_service_cod": op_service_cod,
"op_service_denumire": op_service_denumire, "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).
""" """

View File

@@ -1,12 +1,11 @@
"""Dashboard Jinja2 + HTMX (server-rendered, zero build). """Dashboard Jinja2 + HTMX (server-rendered, zero build).
Schelet cu stari explicite: empty (coada goala), banner alerta blocate, Schelet cu stari explicite: empty (coada goala), banner alerta blocate,
worker viu/mort, ultimul login RAR. Editor mapari + browser nomenclator + worker viu/mort, ultimul login RAR. Editor mapari + browser nomenclator.
export CSV + stare "RAR indisponibil" = de adaugat (plan.md sect. 4 + design-review).
U5 — Rute web pentru import fisier (upload → mapare coloane → preview → confirma → trimite). Rute web pentru import fisier (upload → mapare coloane → preview → confirma → trimite).
Consuma endpointurile backend din import_router (helper-e interne) fara a le modifica. Consuma helper-e interne din import_router fara a le modifica. Toate rutele /_import/*
Toate rutele /_import/* returneaza fragmente HTML targetate pe #import-section prin HTMX. returneaza fragmente HTML targetate pe #import-section prin HTMX.
""" """
from __future__ import annotations from __future__ import annotations
@@ -83,12 +82,12 @@ from ..mapping import (
text_rules_overlap, text_rules_overlap,
) )
# Campuri canonice cu eticheta umana pentru dropdown mapare coloane (U5) # Campuri canonice cu eticheta umana pentru dropdown mapare coloane
_CANONICAL_FIELDS = [(k, v[0]) for k, v in _CANONICAL_SYNONYMS.items()] _CANONICAL_FIELDS = [(k, v[0]) for k, v in _CANONICAL_SYNONYMS.items()]
router = APIRouter(tags=["web"]) router = APIRouter(tags=["web"])
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates")) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
# Expune parse_erori in toate template-urile (US-006, PRD 5.4) # Expune parse_erori in toate template-urile
templates.env.globals["parse_erori"] = parse_erori templates.env.globals["parse_erori"] = parse_erori
_BLOCKED = ("error", "needs_data", "needs_mapping") _BLOCKED = ("error", "needs_data", "needs_mapping")
@@ -98,7 +97,7 @@ def _ctx(request: Request, **extra) -> dict:
"""Context de baza pentru template-uri cu formulare: include mereu csrf_token. """Context de baza pentru template-uri cu formulare: include mereu csrf_token.
Previne lock-out in prod (web_auth_required=True): orice re-randare de eroare Previne lock-out in prod (web_auth_required=True): orice re-randare de eroare
trebuie sa includa csrf_token negol altfel urmatorul submit da 403 (task #8). trebuie sa includa csrf_token negol altfel urmatorul submit da 403.
""" """
return {"request": request, "csrf_token": get_csrf_token(request), **extra} return {"request": request, "csrf_token": get_csrf_token(request), **extra}
@@ -161,15 +160,14 @@ def _rar_state(hb, worker_alive: bool) -> str:
return "indisponibil?" if age > 108000 else "ok" return "indisponibil?" if age > 108000 else "ok"
# US-002: "import" nu mai e tab separat — importul traieste pe Acasa. ?tab=import # "import" si "coada" nu mai sunt tab-uri separate — importul si Trimiterile sunt
# cade pe Acasa (tab invalid -> fallback "acasa" in dashboard()), fara 404. # sectiuni pe Acasa. ?tab=import / ?tab=coada cad pe Acasa (fallback in dashboard()),
# US-003 (3.6): "coada" (Trimiteri) nu mai e tab — Trimiterile sunt sectiune pe Acasa. # fara 404 si fara fragment orfan.
# ?tab=coada cade tot pe Acasa (fallback), fara 404, fara fragment orfan.
_TABS_VALIDE = {"acasa", "mapari", "cont", "nomenclator", "integrare", "jurnal"} _TABS_VALIDE = {"acasa", "mapari", "cont", "nomenclator", "integrare", "jurnal"}
def _get_acasa_context(request: Request, conn, account_id: int) -> dict: def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
"""Calculeaza contextul pentru panoul Acasa (US-005). """Calculeaza contextul pentru panoul Acasa.
Scoped pe contul sesiunii — identic cu pattern-ul din restul rutelor (NULL->1). Scoped pe contul sesiunii — identic cu pattern-ul din restul rutelor (NULL->1).
""" """
@@ -197,8 +195,8 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
).fetchone() ).fetchone()
are_cheie_folosita = row_key is not None are_cheie_folosita = row_key is not None
# US-003 (3.6): contorul de atentie (blocate) se reflecta in heading-ul # Contorul de atentie (blocate) se reflecta in heading-ul sectiunii
# sectiunii "Trimiterile tale" de pe Acasa, nu pe un tab disparut. # "Trimiterile tale" de pe Acasa.
counts = _status_counts(conn, account_id) counts = _status_counts(conn, account_id)
blocate_total = sum(counts.get(s, 0) for s in _BLOCKED) blocate_total = sum(counts.get(s, 0) for s in _BLOCKED)
@@ -212,7 +210,7 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
"pills_categorii": _pills_categorii(counts), "pills_categorii": _pills_categorii(counts),
# Semnatura datelor: nudge-ul "Date noi" o compara la fiecare poll usor. # Semnatura datelor: nudge-ul "Date noi" o compara la fiecare poll usor.
"versiune_trimiteri": _trimiteri_versiune(conn, account_id), "versiune_trimiteri": _trimiteri_versiune(conn, account_id),
# US-002: Acasa include caseta de upload -> are nevoie de csrf_token # Acasa include caseta de upload -> are nevoie de csrf_token
"csrf_token": get_csrf_token(request), "csrf_token": get_csrf_token(request),
} }
@@ -220,8 +218,8 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
def _render_panel_acasa(request: Request, conn=None, account_id: int = 1, status: str | None = None) -> str: def _render_panel_acasa(request: Request, conn=None, account_id: int = 1, status: str | None = None) -> str:
"""Randeaza panoul Acasa ca string HTML. """Randeaza panoul Acasa ca string HTML.
`status` (US-014/T13): deep-link `?tab=acasa&status=error` pre-selecteaza filtrul de `status`: deep-link `?tab=acasa&status=error` pre-selecteaza filtrul de stare in
stare in sectiunea Trimiteri, astfel ca lista se incarca direct filtrata (nu dead-end). sectiunea Trimiteri, astfel ca lista se incarca direct filtrata (nu dead-end).
""" """
if conn is None: if conn is None:
return templates.get_template("_acasa.html").render( return templates.get_template("_acasa.html").render(
@@ -243,8 +241,8 @@ def _render_panel_import(request: Request) -> str:
def _render_panel_coada(request: Request, conn=None, account_id: int = 1) -> str: def _render_panel_coada(request: Request, conn=None, account_id: int = 1) -> str:
"""US-003 (3.6): "coada" nu mai e panou propriu — serveste continutul Acasa """"coada" nu mai e panou propriu — serveste continutul Acasa (Trimiterile sunt
(Trimiterile sunt sectiune pe Acasa). Pastrat ca alias pentru deep-link/bookmark vechi.""" sectiune pe Acasa). Pastrat ca alias pentru deep-link/bookmark vechi."""
return _render_panel_acasa(request, conn, account_id) return _render_panel_acasa(request, conn, account_id)
@@ -334,10 +332,10 @@ def _jurnal_context(
data_de: str | None = None, data_pana: str | None = None, data_de: str | None = None, data_pana: str | None = None,
cont: str | None = None, page: int = 0, cont: str | None = None, page: int = 0,
) -> dict: ) -> dict:
"""Context pentru tab-ul Jurnal (US-006): evenimente paginate + filtre + scope. """Context pentru tab-ul Jurnal: evenimente paginate + filtre + scope.
Admin -> vede TOT, cu filtru optional pe cont. Non-admin -> DOAR evenimentele Admin -> vede TOT, cu filtru optional pe cont. Non-admin -> DOAR evenimentele
contului sau (regula NULL->cont 1, ca restul UI-ului). Decizie §5. contului sau (regula NULL->cont 1, ca restul UI-ului).
""" """
admin = is_account_admin(conn, account_id) admin = is_account_admin(conn, account_id)
tip = (tip or "").strip() or None tip = (tip or "").strip() or None
@@ -397,7 +395,7 @@ def _jurnal_context(
def _render_panel_jurnal(request: Request, conn, account_id: int) -> str: def _render_panel_jurnal(request: Request, conn, account_id: int) -> str:
"""Randeaza panoul Jurnal ca string HTML (US-006).""" """Randeaza panoul Jurnal ca string HTML."""
return templates.get_template("_jurnal.html").render(_jurnal_context(request, conn, account_id)) return templates.get_template("_jurnal.html").render(_jurnal_context(request, conn, account_id))
@@ -424,20 +422,20 @@ def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str, sta
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -> HTMLResponse: def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -> HTMLResponse:
"""Dashboard principal cu tab-uri (US-003). """Dashboard principal cu tab-uri.
Parametrul ?tab= permite deep-link pe orice sectiune; panoul activ e randat Parametrul ?tab= permite deep-link pe orice sectiune; panoul activ e randat
server-side la full load (fara palpaiere la refresh, degradare gratiosa fara JS). server-side la full load (fara palpaiere la refresh, degradare gratiosa fara JS).
Tab invalid -> fallback la 'acasa'. `?status=` (US-014/T13) pre-filtreaza lista Tab invalid -> fallback la 'acasa'. `?status=` pre-filtreaza lista Trimiteri de
Trimiteri de pe Acasa (deep-link din banner-ul "Necesita atentia ta"). pe Acasa (deep-link din banner-ul "Necesita atentia ta").
""" """
account_id = require_login(request) account_id = require_login(request)
active_tab = tab if tab in _TABS_VALIDE else "acasa" active_tab = tab if tab in _TABS_VALIDE else "acasa"
conn = get_connection() conn = get_connection()
try: try:
panel_html = _render_panel_for_tab(request, conn, account_id, active_tab, status=status) panel_html = _render_panel_for_tab(request, conn, account_id, active_tab, status=status)
# Badge contoare pe tab-uri (US-011): needs_mapping -> Mapari. Blocatele # Badge contoare pe tab-uri: needs_mapping -> Mapari. Blocatele se reflecta in
# (fost badge "coada") se reflecta acum in heading-ul sectiunii Trimiteri (US-003). # heading-ul sectiunii Trimiteri.
counts = _status_counts(conn, account_id) counts = _status_counts(conn, account_id)
badges = { badges = {
"mapari": counts.get("needs_mapping", 0), "mapari": counts.get("needs_mapping", 0),
@@ -460,7 +458,7 @@ def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -
@router.get("/_fragments/acasa", response_class=HTMLResponse) @router.get("/_fragments/acasa", response_class=HTMLResponse)
def fragment_acasa(request: Request) -> HTMLResponse: def fragment_acasa(request: Request) -> HTMLResponse:
"""Fragment HTMX pentru tab-ul Acasa (US-003, US-005).""" """Fragment HTMX pentru tab-ul Acasa."""
account_id = require_login(request) account_id = require_login(request)
conn = get_connection() conn = get_connection()
try: try:
@@ -472,16 +470,16 @@ def fragment_acasa(request: Request) -> HTMLResponse:
@router.get("/_fragments/import", response_class=HTMLResponse) @router.get("/_fragments/import", response_class=HTMLResponse)
def fragment_import(request: Request) -> HTMLResponse: def fragment_import(request: Request) -> HTMLResponse:
"""Fragment HTMX pentru tab-ul Import — include zona de upload (US-003).""" """Fragment HTMX pentru tab-ul Import — include zona de upload."""
require_login(request) require_login(request)
return templates.TemplateResponse("_upload.html", _ctx(request)) return templates.TemplateResponse("_upload.html", _ctx(request))
@router.get("/_fragments/coada", response_class=HTMLResponse) @router.get("/_fragments/coada", response_class=HTMLResponse)
def fragment_coada(request: Request) -> HTMLResponse: def fragment_coada(request: Request) -> HTMLResponse:
"""US-003 (3.6): "coada" nu mai are fragment propriu. Serveste continutul Acasa """"coada" nu mai are fragment propriu. Serveste continutul Acasa (Trimiterile sunt
(Trimiterile sunt sectiune permanenta pe Acasa) — evita un fragment `_coada.html` sectiune permanenta pe Acasa) — evita un fragment `_coada.html` orfan din
orfan din bookmark-uri/HTMX vechi. Nu da 404.""" bookmark-uri/HTMX vechi. Nu da 404."""
account_id = require_login(request) account_id = require_login(request)
conn = get_connection() conn = get_connection()
try: try:
@@ -528,7 +526,7 @@ def fragment_jurnal(
cont: str | None = None, cont: str | None = None,
page: int = 0, page: int = 0,
) -> HTMLResponse: ) -> HTMLResponse:
"""Tab Jurnal (US-006): evenimente app_events paginate + filtre, scoped pe cont. """Tab Jurnal: evenimente app_events paginate + filtre, scoped pe cont.
Admin vede tot (filtru optional pe cont); non-admin doar evenimentele proprii. Admin vede tot (filtru optional pe cont); non-admin doar evenimentele proprii.
""" """
@@ -563,8 +561,8 @@ def fragment_banner(request: Request) -> HTMLResponse:
def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]: def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]:
"""Construieste lista [(eticheta, n), ...] pentru starile blocate cu n > 0. """Construieste lista [(eticheta, n), ...] pentru starile blocate cu n > 0.
Ordinea: needs_mapping, needs_data, error — aceeasi ca in PRD. Ordinea: needs_mapping, needs_data, error. Returneaza lista goala daca nu
Returneaza lista goala daca nu exista nicio stare blocata. exista nicio stare blocata.
""" """
rezultat = [] rezultat = []
for status in ("needs_mapping", "needs_data", "error"): for status in ("needs_mapping", "needs_data", "error"):
@@ -575,13 +573,12 @@ def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]:
def _pills_categorii(counts: dict[str, int]) -> list[dict]: def _pills_categorii(counts: dict[str, int]) -> list[dict]:
"""Pill-uri pentru starile cu problema (US-003 PRD 5.10). """Pill-uri pentru starile cu problema.
Inlocuieste _blocate_actionabil (care incarca PII/VIN per rand). Reutilizeaza contoarele deja calculate din _status_counts (fara PII/VIN per rand).
Reutilizeaza contoarele deja calculate din _status_counts. Returneaza lista goala daca nu exista nicio stare blocata.
Returneza lista goala daca nu exista nicio stare blocata.
""" """
# DESIGN.md §Componente: Lipsa cod = --warn (chihlimbar), celelalte categorii = --err (rosu). # Lipsa cod = --warn (chihlimbar), celelalte categorii = --err (rosu).
# Culoarea e CSS variable name (nu clasa), injectata direct in style tag al pill-ului, # Culoarea e CSS variable name (nu clasa), injectata direct in style tag al pill-ului,
# pentru ca s-needs_mapping in base.html e tot --err (incorect pentru pill). # pentru ca s-needs_mapping in base.html e tot --err (incorect pentru pill).
PILL_DEFS = [ PILL_DEFS = [
@@ -598,7 +595,7 @@ def _pills_categorii(counts: dict[str, int]) -> list[dict]:
@router.get("/_fragments/status", response_class=HTMLResponse) @router.get("/_fragments/status", response_class=HTMLResponse)
def fragment_status(request: Request) -> HTMLResponse: def fragment_status(request: Request) -> HTMLResponse:
"""Bara de status persistenta cu etichete umane (US-002, PRD 3.4). """Bara de status persistenta cu etichete umane.
Scoped pe contul sesiunii. Expune starea worker, legatura RAR, ultima Scoped pe contul sesiunii. Expune starea worker, legatura RAR, ultima
autentificare, contorii de coada si defalcarea blocatelor pe motiv. autentificare, contorii de coada si defalcarea blocatelor pe motiv.
@@ -623,7 +620,7 @@ def fragment_status(request: Request) -> HTMLResponse:
"request": request, "request": request,
"worker_lbl": worker_lbl, "worker_lbl": worker_lbl,
"rar_lbl": rar_lbl, "rar_lbl": rar_lbl,
# Stari binare pentru bife accesibile (US-001 PRD 3.5): glifa + culoare # Stari binare pentru bife accesibile: glifa + culoare
"worker_ok": worker_alive, "worker_ok": worker_alive,
"rar_ok": rar_ok, "rar_ok": rar_ok,
"eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR, "eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
@@ -657,9 +654,8 @@ def _iso_date_prefix(value: object) -> str | None:
Permite filtrarea dupa data_prestatie chiar daca valoarea contine ora/minut/secunda Permite filtrarea dupa data_prestatie chiar daca valoarea contine ora/minut/secunda
(ex. '2026-06-20 14:35:07' sau '2026-06-20T14:35:07') — extrage portiunea de data (ex. '2026-06-20 14:35:07' sau '2026-06-20T14:35:07') — extrage portiunea de data
fara a exclude timestamp-urile (bug-ul fix US-001: _is_iso_date cerea len==10). fara a exclude timestamp-urile. Valori care nu incep cu o data ISO valida
Valori care nu incep cu o data ISO valida (ex. '05.12.2024') intorc None si (ex. '05.12.2024') intorc None si sunt excluse din filtru.
sunt excluse din filtru — comportament actual pastrat.
""" """
s = str(value or "").strip() s = str(value or "").strip()
if len(s) < 10: if len(s) < 10:
@@ -673,16 +669,16 @@ def _iso_date_prefix(value: object) -> str | None:
# Stari care semnaleaza o problema ce necesita atentia operatorului. Eticheta umana # Stari care semnaleaza o problema ce necesita atentia operatorului. Eticheta umana
# scurta de pe rand (US-001, R1) e ne-goala DOAR pe acestea; pe queued/sending/sent e "". # scurta de pe rand e ne-goala DOAR pe acestea; pe queued/sending/sent e "".
_STARI_CU_PROBLEMA = ("error", "needs_data", "needs_mapping") _STARI_CU_PROBLEMA = ("error", "needs_data", "needs_mapping")
def _eticheta_problema(status: str, motiv: str) -> str: def _eticheta_problema(status: str, motiv: str) -> str:
"""Eticheta umana scurta a problemei pentru randul de tabel (US-001, R1). """Eticheta umana scurta a problemei pentru randul de tabel.
Reutilizeaza `motiv` (motiv_uman, deja calculat in randul de view) si cade pe Reutilizeaza `motiv` (motiv_uman, deja calculat in randul de view) si cade pe
`eticheta_scurta` cand motivul e gol — NU re-parseaza `rar_error` (R1: DRY, fara `eticheta_scurta` cand motivul e gol — NU re-parseaza `rar_error` (DRY, fara al
al 3-lea decoder). Codul BRUT de catalog ramane doar pentru modal, nu pe rand. 3-lea decoder). Codul BRUT de catalog ramane doar pentru modal, nu pe rand.
Sir gol pe stari fara problema (queued/sending/sent); ne-gol pe error/needs_*. Sir gol pe stari fara problema (queued/sending/sent); ne-gol pe error/needs_*.
Defensiv: motiv_uman nu arunca, iar starile cu problema au intotdeauna eticheta Defensiv: motiv_uman nu arunca, iar starile cu problema au intotdeauna eticheta
@@ -694,13 +690,13 @@ def _eticheta_problema(status: str, motiv: str) -> str:
def _submission_row_view(r) -> dict: def _submission_row_view(r) -> dict:
"""Imbogateste un rand de submission cu campuri afisabile umane (US-003/US-004).""" """Imbogateste un rand de submission cu campuri afisabile umane."""
eticheta = eticheta_stare(r["status"]) eticheta = eticheta_stare(r["status"])
motiv = motiv_uman(r["status"], r["rar_error"]) motiv = motiv_uman(r["status"], r["rar_error"])
return { return {
"id": r["id"], "id": r["id"],
"status": r["status"], "status": r["status"],
# PRD 5.8 US-007/US-006: pill = eticheta scurta; textul lung ramane ca tooltip (title=). # pill = eticheta scurta; textul lung ramane ca tooltip (title=).
"stare_scurt": eticheta_scurta(r["status"]), "stare_scurt": eticheta_scurta(r["status"]),
"stare_text": eticheta[0], "stare_text": eticheta[0],
"stare_css": eticheta[2], "stare_css": eticheta[2],
@@ -708,15 +704,15 @@ def _submission_row_view(r) -> dict:
"id_prezentare": r["id_prezentare"], "id_prezentare": r["id_prezentare"],
"updated_at": format_data_rar(r["updated_at"]), "updated_at": format_data_rar(r["updated_at"]),
"motiv": motiv, "motiv": motiv,
# US-001/R1: eticheta umana scurta a problemei sub pill (text, nu cod brut). # eticheta umana scurta a problemei sub pill (text, nu cod brut).
"eticheta_problema": _eticheta_problema(r["status"], motiv), "eticheta_problema": _eticheta_problema(r["status"], motiv),
# US-011: randurile blocate (error/needs_data/needs_mapping) sunt selectabile # randurile blocate (error/needs_data/needs_mapping) sunt selectabile pentru
# pentru stergere bulk; sent/sending/queued raman read-only (fara checkbox). # stergere bulk; sent/sending/queued raman read-only (fara checkbox).
"gestionabil": r["status"] in _GESTIONABILE_WEB, "gestionabil": r["status"] in _GESTIONABILE_WEB,
} }
_PAGE_SIZE = 25 # Marime pagina fixa (US-004 PRD 5.10) _PAGE_SIZE = 25 # Marime pagina fixa
@router.get("/_fragments/submissions", response_class=HTMLResponse) @router.get("/_fragments/submissions", response_class=HTMLResponse)
@@ -728,9 +724,9 @@ def fragment_submissions(
data_pana: str | None = None, data_pana: str | None = None,
page: int = 1, page: int = 1,
) -> HTMLResponse: ) -> HTMLResponse:
"""Tabel Trimiteri, scoped pe cont, cu filtre optionale si paginare (US-009, US-004). """Tabel Trimiteri, scoped pe cont, cu filtre optionale si paginare.
US-004 H1: totalul se calculeaza DIFERIT dupa tipul de filtru: Totalul se calculeaza DIFERIT dupa tipul de filtru:
- FARA filtru Python (status-only / niciun filtru): SQL COUNT(*) + LIMIT/OFFSET - FARA filtru Python (status-only / niciun filtru): SQL COUNT(*) + LIMIT/OFFSET
- CU filtru vehicul/data activ: fetch-all -> filtreaza Python -> total=len -> slice - CU filtru vehicul/data activ: fetch-all -> filtreaza Python -> total=len -> slice
SQL COUNT/LIMIT pe calea cu filtru Python ar da total gresit (taie inainte de filtru). SQL COUNT/LIMIT pe calea cu filtru Python ar da total gresit (taie inainte de filtru).
@@ -756,8 +752,8 @@ def fragment_submissions(
where_sql = " AND ".join(where) where_sql = " AND ".join(where)
if filtru_python: if filtru_python:
# Calea B: fetch-all, filtreaza in Python, slice (US-004 H1) # Calea B: fetch-all, filtreaza in Python, slice.
# FARA LIMIT — altfel paginile >8 ar disparea silentios (bug PRD H1) # FARA LIMIT — altfel paginile >8 ar disparea silentios.
rows_db = conn.execute( rows_db = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, " "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC", f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC",
@@ -773,7 +769,7 @@ def fragment_submissions(
if vehicul_q not in hay: if vehicul_q not in hay:
continue continue
if data_de or data_pana: if data_de or data_pana:
# Extragem portiunea YYYY-MM-DD (US-001 fix). # Extragem portiunea YYYY-MM-DD.
d_prefix = _iso_date_prefix(prez["data_prestatie"]) d_prefix = _iso_date_prefix(prez["data_prestatie"])
if d_prefix is None: if d_prefix is None:
continue continue
@@ -785,7 +781,7 @@ def fragment_submissions(
total = len(view_all) total = len(view_all)
pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1 pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1
page = max(1, min(page, pages)) # clamp H2 page = max(1, min(page, pages)) # clamp la nr. de pagini
offset = (page - 1) * _PAGE_SIZE offset = (page - 1) * _PAGE_SIZE
view = view_all[offset:offset + _PAGE_SIZE] view = view_all[offset:offset + _PAGE_SIZE]
@@ -796,7 +792,7 @@ def fragment_submissions(
).fetchone()[0] ).fetchone()[0]
pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1 pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1
page = max(1, min(page, pages)) # clamp H2 page = max(1, min(page, pages)) # clamp la nr. de pagini
offset = (page - 1) * _PAGE_SIZE offset = (page - 1) * _PAGE_SIZE
rows_db = conn.execute( rows_db = conn.execute(
@@ -815,13 +811,13 @@ def fragment_submissions(
"rows": view, "rows": view,
"filtru_activ": filtru_activ, "filtru_activ": filtru_activ,
"csrf_token": get_csrf_token(request), "csrf_token": get_csrf_token(request),
# Paginare (US-004) # Paginare
"total": total, "total": total,
"page": page, "page": page,
"pages": pages, "pages": pages,
"page_start": page_start, "page_start": page_start,
"page_end": page_end, "page_end": page_end,
# Filtre curente pentru linkurile de paginare (pastreaza filtrele, H2) # Filtre curente pentru linkurile de paginare (pastreaza filtrele)
"f_status": status or "", "f_status": status or "",
"f_vehicul": vehicul_q or "", "f_vehicul": vehicul_q or "",
"f_data_de": data_de or "", "f_data_de": data_de or "",
@@ -835,17 +831,17 @@ def fragment_submissions(
conn.close() conn.close()
# Stari ne-trimise blocate pe care le putem corecta inline (US-010). # Stari ne-trimise blocate pe care le putem corecta inline.
_CORECTABILE = ("needs_data", "needs_mapping") _CORECTABILE = ("needs_data", "needs_mapping")
# US-006b: stari cu select editabil cod_prestatie (superset al _CORECTABILE: error # Stari cu select editabil cod_prestatie (superset al _CORECTABILE: error primeste
# primeste select in formularul /repune, nu in /corecteaza — fara schimbare de vehicle fields). # select in formularul /repune, nu in /corecteaza — fara schimbare de vehicle fields).
_EDITABILE_OP = ("needs_data", "needs_mapping", "error") _EDITABILE_OP = ("needs_data", "needs_mapping", "error")
# Stari gestionabile prin lifecycle web (US-011): sterge / re-pune in coada. # Stari gestionabile prin lifecycle web: sterge / re-pune in coada.
_GESTIONABILE_WEB = ("error", "needs_data", "needs_mapping") _GESTIONABILE_WEB = ("error", "needs_data", "needs_mapping")
def _render_submissions(request: Request, conn, account_id: int) -> HTMLResponse: def _render_submissions(request: Request, conn, account_id: int) -> HTMLResponse:
"""Re-randeaza lista Trimiteri (fara filtre) — folosit dupa actiuni bulk (US-011).""" """Re-randeaza lista Trimiteri (fara filtre) — folosit dupa actiuni bulk."""
scope_sql, scope_params = account_scope_clause(account_id) scope_sql, scope_params = account_scope_clause(account_id)
rows = conn.execute( rows = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, " "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
@@ -865,7 +861,7 @@ def _render_submissions(request: Request, conn, account_id: int) -> HTMLResponse
def _payload_form_values(payload_json) -> dict: def _payload_form_values(payload_json) -> dict:
"""Valori brute pentru prefill-ul formularului de corectie (US-010).""" """Valori brute pentru prefill-ul formularului de corectie."""
try: try:
data = json.loads(payload_json) if payload_json else {} data = json.loads(payload_json) if payload_json else {}
if not isinstance(data, dict): if not isinstance(data, dict):
@@ -882,7 +878,7 @@ def _payload_form_values(payload_json) -> dict:
def _nemapate_pentru_submission(row, nomenclator: list[dict]) -> list[dict]: def _nemapate_pentru_submission(row, nomenclator: list[dict]) -> list[dict]:
"""Operatiile nemapate ale UNUI submission needs_mapping, cu sugestii fuzzy (PRD 5.7). """Operatiile nemapate ale UNUI submission needs_mapping, cu sugestii fuzzy.
Echivalentul `pending_unmapped` restrans la un singur rand: parseaza payload_json, Echivalentul `pending_unmapped` restrans la un singur rand: parseaza payload_json,
aduna prestatiile fara cod_prestatie (cu cod_op_service) si ataseaza sugestii din aduna prestatiile fara cod_prestatie (cu cod_op_service) si ataseaza sugestii din
@@ -921,12 +917,12 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
"""Context pentru _trimitere_detaliu.html dintr-un rand de submission. """Context pentru _trimitere_detaliu.html dintr-un rand de submission.
`conn`+`account_id` (optional): cand sunt date si randul e needs_mapping, expune `conn`+`account_id` (optional): cand sunt date si randul e needs_mapping, expune
`nemapate_inline` + `nomenclator` pentru maparea inline din panou (PRD 5.7). `nemapate_inline` + `nomenclator` pentru maparea inline din panou.
""" """
eticheta = eticheta_stare(row["status"]) eticheta = eticheta_stare(row["status"])
nemapate_inline: list[dict] = [] nemapate_inline: list[dict] = []
nomenclator: list[dict] = [] nomenclator: list[dict] = []
# Variabila interna: nomenclatorul complet (incarcat pentru needs_mapping, refolosit pt US-006) # Nomenclatorul complet, incarcat pentru needs_mapping si refolosit mai jos.
_nomenclator_complet: list[dict] = [] _nomenclator_complet: list[dict] = []
if conn is not None and row["status"] == "needs_mapping": if conn is not None and row["status"] == "needs_mapping":
# Un singur SELECT pe nomenclator: il refolosim si pentru sugestii si pentru dropdown. # Un singur SELECT pe nomenclator: il refolosim si pentru sugestii si pentru dropdown.
@@ -934,14 +930,14 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
nemapate_inline = _nemapate_pentru_submission(row, _nomenclator_complet) nemapate_inline = _nemapate_pentru_submission(row, _nomenclator_complet)
nomenclator = _nomenclator_complet if nemapate_inline else [] nomenclator = _nomenclator_complet if nemapate_inline else []
# US-006/US-006b: nomenclator pentru selectul cod_prestatie — needs_data/needs_mapping (in # Nomenclator pentru selectul cod_prestatie — needs_data/needs_mapping (in formularul
# formularul /corecteaza) + error (in formularul /repune). Refoloseste _nomenclator_complet # /corecteaza) + error (in formularul /repune). Refoloseste _nomenclator_complet daca
# daca e deja incarcat (needs_mapping), altfel incarca fresh. # e deja incarcat (needs_mapping), altfel incarca fresh.
nomenclator_rar: list[dict] = [] nomenclator_rar: list[dict] = []
if conn is not None and row["status"] in _EDITABILE_OP: if conn is not None and row["status"] in _EDITABILE_OP:
nomenclator_rar = _nomenclator_complet if _nomenclator_complet else load_nomenclator(conn) nomenclator_rar = _nomenclator_complet if _nomenclator_complet else load_nomenclator(conn)
# US-006: cod_prestatie curent din prima prestatie (pentru pre-selectare in select) # cod_prestatie curent din prima prestatie (pentru pre-selectare in select)
cod_prestatie_curent = "" cod_prestatie_curent = ""
try: try:
_pd = json.loads(row["payload_json"] or "{}") _pd = json.loads(row["payload_json"] or "{}")
@@ -969,14 +965,14 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
"created_at": format_data_rar(row["created_at"]), "created_at": format_data_rar(row["created_at"]),
"updated_at": format_data_rar(row["updated_at"]), "updated_at": format_data_rar(row["updated_at"]),
"next_attempt_at": format_data_rar(row["next_attempt_at"]), "next_attempt_at": format_data_rar(row["next_attempt_at"]),
# randuri ne-trimise blocate sunt corectabile (US-010); sent/sending nu # randuri ne-trimise blocate sunt corectabile; sent/sending nu
"editabil": row["status"] in _CORECTABILE, "editabil": row["status"] in _CORECTABILE,
# US-011: error/needs_data/needs_mapping pot fi sterse / re-puse in coada # error/needs_data/needs_mapping pot fi sterse / re-puse in coada
"gestionabil": row["status"] in _GESTIONABILE_WEB, "gestionabil": row["status"] in _GESTIONABILE_WEB,
# PRD 5.7: mapare inline (operatii nemapate ale acestui rand + nomenclator) # mapare inline (operatii nemapate ale acestui rand + nomenclator)
"nemapate_inline": nemapate_inline, "nemapate_inline": nemapate_inline,
"nomenclator": nomenclator, "nomenclator": nomenclator,
# US-006: select cod_prestatie pentru stari editabile # select cod_prestatie pentru stari editabile
"nomenclator_rar": nomenclator_rar, "nomenclator_rar": nomenclator_rar,
"cod_prestatie_curent": cod_prestatie_curent, "cod_prestatie_curent": cod_prestatie_curent,
"corectie_msg": message, "corectie_msg": message,
@@ -988,7 +984,7 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
def _fetch_submission_scoped(conn, account_id: int, submission_id: int): def _fetch_submission_scoped(conn, account_id: int, submission_id: int):
"""Randul scoped pe cont sau None (404 cross-account, nu confirmam existenta — B3).""" """Randul scoped pe cont sau None (404 cross-account, nu confirmam existenta)."""
scope_sql, scope_params = account_scope_clause(account_id) scope_sql, scope_params = account_scope_clause(account_id)
return conn.execute( return conn.execute(
f"SELECT * FROM submissions WHERE id=? AND {scope_sql}", f"SELECT * FROM submissions WHERE id=? AND {scope_sql}",
@@ -996,14 +992,14 @@ def _fetch_submission_scoped(conn, account_id: int, submission_id: int):
).fetchone() ).fetchone()
# Campuri afisate in detaliul trimiterii (panou dedicat US-004). payload_json e # Campuri afisate in detaliul trimiterii. payload_json e plaintext si se foloseste
# plaintext si se foloseste doar pentru campurile derivate (prezentare_din_payload). # doar pentru campurile derivate (prezentare_din_payload).
@router.get("/_fragments/trimitere/{submission_id}", response_class=HTMLResponse) @router.get("/_fragments/trimitere/{submission_id}", response_class=HTMLResponse)
def fragment_trimitere_detaliu(request: Request, submission_id: int) -> HTMLResponse: def fragment_trimitere_detaliu(request: Request, submission_id: int) -> HTMLResponse:
"""Detaliu complet al unei trimiteri, in panou dedicat (#trimitere-detaliu). """Detaliu complet al unei trimiteri, in panou dedicat (#trimitere-detaliu).
Scoped pe contul sesiunii: 404 daca randul nu exista SAU apartine altui cont Scoped pe contul sesiunii: 404 daca randul nu exista SAU apartine altui cont
(acelasi mesaj, nu confirmam existenta — vezi B3/router.py). (acelasi mesaj, nu confirmam existenta).
""" """
account_id = require_login(request) account_id = require_login(request)
conn = get_connection() conn = get_connection()
@@ -1021,7 +1017,7 @@ def fragment_trimitere_detaliu(request: Request, submission_id: int) -> HTMLResp
@router.post("/trimitere/{submission_id}/mapeaza", response_class=HTMLResponse) @router.post("/trimitere/{submission_id}/mapeaza", response_class=HTMLResponse)
async def post_mapeaza_inline(request: Request, submission_id: int) -> HTMLResponse: async def post_mapeaza_inline(request: Request, submission_id: int) -> HTMLResponse:
"""Mapare inline din panoul de detaliu (PRD 5.7): alege cod RAR pentru o operatie nemapata. """Mapare inline din panoul de detaliu: alege cod RAR pentru o operatie nemapata.
Reutilizeaza EXACT save_mapping + reresolve_account (ca tab-ul Mapari) — fara logica Reutilizeaza EXACT save_mapping + reresolve_account (ca tab-ul Mapari) — fara logica
noua de clasificare. Re-rezolva scoped pe batch-ul randului (canal API batch_id IS NULL noua de clasificare. Re-rezolva scoped pe batch-ul randului (canal API batch_id IS NULL
@@ -1108,10 +1104,9 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
if isinstance(val, str) and val.strip() != "": if isinstance(val, str) and val.strip() != "":
content[camp] = val.strip() content[camp] = val.strip()
# US-006: injectare cod_prestatie din form INAINTE de resolve_prestatii. # Injectare cod_prestatie din form INAINTE de resolve_prestatii. Oglindeste
# Oglindeste validarea din post_mapeaza_inline (nomenclator check). Codul nou # validarea din post_mapeaza_inline (nomenclator check). Codul nou e injectat in
# e injectat in prima prestatie (index 0); build_key il include in hash (CLAUDE.md # prima prestatie (index 0); build_key il include in hash.
# invariant "build_key hashuieste cod_prestatie, idempotency.py:34").
_cod_raw = form.get("cod_prestatie") _cod_raw = form.get("cod_prestatie")
cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "") cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "")
if cod_prestatie_form: if cod_prestatie_form:
@@ -1143,7 +1138,7 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules) resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules)
content["prestatii"] = resolved content["prestatii"] = resolved
# US-010: telemetrie pentru itemii rezolvati prin regula text (calea corectie web). # telemetrie pentru itemii rezolvati prin regula text (calea corectie web).
_emite_text_rule_hits(conn, account_id, row["id"], resolved) _emite_text_rule_hits(conn, account_id, row["id"], resolved)
# Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie. # Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie.
@@ -1238,8 +1233,8 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
"_trimitere_detaliu.html", "_trimitere_detaliu.html",
_detaliu_ctx(request, row2, message="Corectat — randul a fost re-pus in coada."), _detaliu_ctx(request, row2, message="Corectat — randul a fost re-pus in coada."),
) )
# PRD 5.9 US-003 (R5): pe succes, lista se reincarca (trimiteriChanged) si modalul # Pe succes, lista se reincarca (trimiteriChanged) si modalul se inchide
# se inchide (inchideModal). After-settle ca inchiderea sa urmeze swap-ul fragmentului. # (inchideModal). After-settle ca inchiderea sa urmeze swap-ul fragmentului.
resp.headers["HX-Trigger-After-Settle"] = "trimiteriChanged, inchideModal" resp.headers["HX-Trigger-After-Settle"] = "trimiteriChanged, inchideModal"
return resp return resp
finally: finally:
@@ -1247,26 +1242,26 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
# =========================================================================== # # =========================================================================== #
# US-011 — Lifecycle trimiteri blocate din dashboard: sterge / re-pune in coada # # Lifecycle trimiteri blocate din dashboard: sterge / re-pune in coada. #
# Peste helper-ul US-009 (submissions_admin). CSRF enforce; scoped pe sesiune. # # Peste helper-ul submissions_admin. CSRF enforce; scoped pe sesiune. #
# =========================================================================== # # =========================================================================== #
@router.post("/trimitere/{submission_id}/repune", response_class=HTMLResponse) @router.post("/trimitere/{submission_id}/repune", response_class=HTMLResponse)
async def post_repune_trimitere(request: Request, submission_id: int) -> HTMLResponse: async def post_repune_trimitere(request: Request, submission_id: int) -> HTMLResponse:
"""Re-pune in coada un rand blocat (error/needs_data/needs_mapping) din dashboard. """Re-pune in coada un rand blocat (error/needs_data/needs_mapping) din dashboard.
US-006b: daca randul e in starea `error` si formularul contine `cod_prestatie`, Daca randul e in starea `error` si formularul contine `cod_prestatie`, actualizeaza
actualizeaza codul in payload, recalculeaza cheia de idempotency si re-pun in coada codul in payload, recalculeaza cheia de idempotency si re-pune in coada direct (fara
direct (fara `requeue_submission`, care nu actualizeaza cheia). Scoped pe sesiune `requeue_submission`, care nu actualizeaza cheia). Scoped pe sesiune (404
(404 cross-account/inexistent, 409 sent/sending). Re-randeaza panoul de detaliu cu cross-account/inexistent, 409 sent/sending). Re-randeaza panoul de detaliu cu starea
starea noua + nudge `trimiteriChanged` pentru lista. noua + nudge `trimiteriChanged` pentru lista.
""" """
account_id = require_login(request) account_id = require_login(request)
form = await request.form() form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or "")) verify_csrf(request, str(form.get("csrf_token") or ""))
conn = get_connection() conn = get_connection()
try: try:
# US-006b: prelucrare cod_prestatie pentru starea error (inaintea requeue_submission # Prelucrare cod_prestatie pentru starea error (inaintea requeue_submission
# standard, care nu actualizeaza cheia de idempotency). # standard, care nu actualizeaza cheia de idempotency).
_cod_raw = form.get("cod_prestatie") _cod_raw = form.get("cod_prestatie")
cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "") cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "")
@@ -1398,7 +1393,7 @@ async def post_sterge_trimitere(request: Request, submission_id: int) -> HTMLRes
resp = HTMLResponse( resp = HTMLResponse(
'<div class="flash" style="margin:0;">Trimitere stearsa.</div>' '<div class="flash" style="margin:0;">Trimitere stearsa.</div>'
) )
# PRD 5.9 US-003 (R5): pe succes, lista se reincarca + modalul se inchide. # Pe succes, lista se reincarca + modalul se inchide.
resp.headers["HX-Trigger-After-Settle"] = "trimiteriChanged, inchideModal" resp.headers["HX-Trigger-After-Settle"] = "trimiteriChanged, inchideModal"
return resp return resp
finally: finally:
@@ -1410,7 +1405,7 @@ async def post_sterge_bulk(request: Request) -> HTMLResponse:
"""Sterge in bloc trimiterile selectate (doar blocate, scoped pe sesiune). """Sterge in bloc trimiterile selectate (doar blocate, scoped pe sesiune).
Sare peste randuri sent/sending (read-only) si cross-account (inexistente) fara a Sare peste randuri sent/sending (read-only) si cross-account (inexistente) fara a
opri operatia — pe modelul panoului admin (PRD 5.5). Re-randeaza lista Trimiteri. opri operatia — pe modelul panoului admin. Re-randeaza lista Trimiteri.
""" """
account_id = require_login(request) account_id = require_login(request)
form = await request.form() form = await request.form()
@@ -1436,7 +1431,7 @@ async def post_sterge_bulk(request: Request) -> HTMLResponse:
def _load_saved_op_mappings(conn, account_id: int) -> list[dict]: def _load_saved_op_mappings(conn, account_id: int) -> list[dict]:
"""Mapari operatie->cod salvate (operations_mapping) ale contului, cu numele """Mapari operatie->cod salvate (operations_mapping) ale contului, cu numele
prestatiei jonctionat din nomenclator (US-005). Scoped pe cont (NOT NULL → simplu).""" prestatiei jonctionat din nomenclator. Scoped pe cont (NOT NULL → simplu)."""
acct = account_or_default(account_id) acct = account_or_default(account_id)
rows = conn.execute( rows = conn.execute(
"SELECT o.id, o.cod_op_service, o.cod_prestatie, o.auto_send, n.nume_prestatie " "SELECT o.id, o.cod_op_service, o.cod_prestatie, o.auto_send, n.nume_prestatie "
@@ -1458,7 +1453,7 @@ def _load_saved_op_mappings(conn, account_id: int) -> list[dict]:
def _load_column_formats(conn, account_id: int) -> list[dict]: def _load_column_formats(conn, account_id: int) -> list[dict]:
"""Formate de coloane salvate (column_mappings) ale contului (US-006). """Formate de coloane salvate (column_mappings) ale contului.
Coloanele afisate = cheile din json_mapare (campurile recunoscute). Scoped pe cont. Coloanele afisate = cheile din json_mapare (campurile recunoscute). Scoped pe cont.
""" """
@@ -1507,7 +1502,7 @@ def _render_mapari(
def fragment_mapari(request: Request) -> HTMLResponse: def fragment_mapari(request: Request) -> HTMLResponse:
"""Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR. """Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR.
Scoped pe contul sesiunii (C6/task#7): pending_unmapped primeste account_id explicit. Scoped pe contul sesiunii: pending_unmapped primeste account_id explicit.
""" """
account_id = require_login(request) account_id = require_login(request)
conn = get_connection() conn = get_connection()
@@ -1547,7 +1542,7 @@ def post_mapare(
# =========================================================================== # # =========================================================================== #
# US-005 — Mapari operatii salvate: editare cod/auto-send + stergere # # Mapari operatii salvate: editare cod/auto-send + stergere. #
# CRUD pe operations_mapping scoped pe sesiune; re-rezolva blocatele la edit. # # CRUD pe operations_mapping scoped pe sesiune; re-rezolva blocatele la edit. #
# =========================================================================== # # =========================================================================== #
@@ -1611,7 +1606,7 @@ def post_sterge_mapare_salvata(
# =========================================================================== # # =========================================================================== #
# US-004 (5.8) — Reguli automate (text): substring -> cod RAR # # Reguli automate (text): substring -> cod RAR. #
# Adaugare/stergere reguli text scoped pe sesiune; salvarea re-rezolva blocajele.# # Adaugare/stergere reguli text scoped pe sesiune; salvarea re-rezolva blocajele.#
# =========================================================================== # # =========================================================================== #
@@ -1628,7 +1623,7 @@ def post_salveaza_regula_text(
Scoped pe contul sesiunii (save_text_rule foloseste account_or_default(sesiune)). Scoped pe contul sesiunii (save_text_rule foloseste account_or_default(sesiune)).
Valideaza cod_prestatie fata de nomenclator INAINTE de save (cod necunoscut -> Valideaza cod_prestatie fata de nomenclator INAINTE de save (cod necunoscut ->
respins inline, fara salvare). La succes: mesaj „Regula salvata. Deblocate: N" respins inline, fara salvare). La succes: mesaj „Regula salvata. Deblocate: N"
+ trigger trimiteriChanged (refresh lista), ca maparea inline (5.7). + trigger trimiteriChanged (refresh lista), ca maparea inline.
""" """
account_id = require_login(request) account_id = require_login(request)
verify_csrf(request, csrf_token) verify_csrf(request, csrf_token)
@@ -1647,7 +1642,7 @@ def post_salveaza_regula_text(
request, conn, account_id, request, conn, account_id,
message=f"Cod RAR necunoscut in nomenclator: {cod}.", message=f"Cod RAR necunoscut in nomenclator: {cod}.",
) )
# US-011: avertisment neblocant daca regula noua se suprapune (substring, # avertisment neblocant daca regula noua se suprapune (substring,
# oricare directie) cu una existenta. Calculam INAINTE de save, fata de # oricare directie) cu una existenta. Calculam INAINTE de save, fata de
# regulile curente, ca pattern-ul nou sa nu se compare cu sine. # regulile curente, ca pattern-ul nou sa nu se compare cu sine.
overlap = text_rules_overlap(pat, load_text_rules(conn, account_id)) overlap = text_rules_overlap(pat, load_text_rules(conn, account_id))
@@ -1696,7 +1691,7 @@ def post_preview_regula_text(
pattern: str = Form(""), pattern: str = Form(""),
csrf_token: str | None = Form(None), csrf_token: str | None = Form(None),
) -> HTMLResponse: ) -> HTMLResponse:
"""Preview pre-salvare (US-009): cate operatii nemapate ar potrivi regula. """Preview pre-salvare: cate operatii nemapate ar potrivi regula.
NU salveaza nimic (zero scriere DB). Normalizeaza pattern-ul cu NU salveaza nimic (zero scriere DB). Normalizeaza pattern-ul cu
normalize_for_match, numara operatiile DISTINCTE nemapate ale contului normalize_for_match, numara operatiile DISTINCTE nemapate ale contului
@@ -1743,7 +1738,7 @@ def post_preview_regula_text(
# =========================================================================== # # =========================================================================== #
# US-006 — Formate de coloane salvate: editare format data + stergere # # Formate de coloane salvate: editare format data + stergere. #
# CRUD pe column_mappings scoped pe sesiune (prin id, verificat pe account). # # CRUD pe column_mappings scoped pe sesiune (prin id, verificat pe account). #
# =========================================================================== # # =========================================================================== #
@@ -1879,7 +1874,7 @@ def _web_compute_preview(
if not raw_rows_db: if not raw_rows_db:
return "Niciun rand in batch." return "Niciun rand in batch."
# Decripteaza randurile + override-urile editate (3.6) # Decripteaza randurile + override-urile editate
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
overrides: list[dict[str, Any]] = [] overrides: list[dict[str, Any]] = []
for r in raw_rows_db: for r in raw_rows_db:
@@ -1907,12 +1902,12 @@ def _web_compute_preview(
json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"]) json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"])
format_data: str | None = mapping_row["format_data"] format_data: str | None = mapping_row["format_data"]
# Mapare operatii (o singura incarcare — Eng#5) # Mapare operatii (o singura incarcare)
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()}
# US-010/US-003 (paritate): preview-ul web trebuie sa aplice ACELEASI reguli text + # Paritate cu commit-ul: preview-ul web trebuie sa aplice ACELEASI reguli text +
# validare nomenclator ca si commit-ul (2426), altfel un rand rezolvabil doar prin # validare nomenclator, altfel un rand rezolvabil doar prin regula text ar fi marcat
# regula text ar fi marcat needs_mapping si exclus din commit. Incarcate o data. # needs_mapping si exclus din commit. Incarcate o data.
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)
@@ -1980,7 +1975,7 @@ def _web_compute_preview(
"idempotency_key": key, "idempotency_key": key,
}) })
# Already_sent: batch lookup (Eng#5 — fara N+1) # Already_sent: batch lookup (fara 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)
@@ -2091,7 +2086,7 @@ async def web_upload_import(
try: try:
sig = _signature(parsed.columns) sig = _signature(parsed.columns)
# Stagingul in DB (tranzactie explicita — Issue 6) # Stagingul in DB (tranzactie explicita)
conn.execute("BEGIN IMMEDIATE") conn.execute("BEGIN IMMEDIATE")
try: try:
cur = conn.execute( cur = conn.execute(
@@ -2330,15 +2325,15 @@ def web_preview_import(
# =========================================================================== # # =========================================================================== #
# US-002 (3.6) — Editare celule in preview: mod editare pe rand. # # Editare celule in preview: mod editare pe rand. #
# Swap pe rand (#preview-row-N) + OOB contoare, NU pe #import-section (D-3.1). # # Swap pe rand (#preview-row-N) + OOB contoare, NU pe #import-section. #
# Status rederivat DOAR prin _resolve_row_for_preview (H2 — fara clasificator). # # Status rederivat DOAR prin _resolve_row_for_preview (fara clasificator). #
# =========================================================================== # # =========================================================================== #
def _preview_one_row(conn, import_id: int, account_id: int, row_index: int): def _preview_one_row(conn, import_id: int, account_id: int, row_index: int):
"""Recalculeaza preview-ul si extrage un singur rand. """Recalculeaza preview-ul si extrage un singur rand.
Statusul e rederivat prin `_resolve_row_for_preview` (H2, fara clasificator duplicat), Statusul e rederivat prin `_resolve_row_for_preview` (fara clasificator duplicat),
iar `_web_compute_preview` persista `resolved_status` pentru toate randurile — astfel iar `_web_compute_preview` persista `resolved_status` pentru toate randurile — astfel
confirmarea (commit) vede starea editata. Intoarce (result, row) sau (mesaj, None).""" confirmarea (commit) vede starea editata. Intoarce (result, row) sau (mesaj, None)."""
result = _web_compute_preview(conn, import_id, account_id) result = _web_compute_preview(conn, import_id, account_id)
@@ -2366,7 +2361,7 @@ def _render_preview_rand(
@router.get("/_import/{import_id}/rand/{row_index}/editare", response_class=HTMLResponse) @router.get("/_import/{import_id}/rand/{row_index}/editare", response_class=HTMLResponse)
def web_rand_editare(request: Request, import_id: int, row_index: int) -> HTMLResponse: def web_rand_editare(request: Request, import_id: int, row_index: int) -> HTMLResponse:
"""Intra in mod editare pe un rand de preview (randul devine FORM propriu, D-3.3).""" """Intra in mod editare pe un rand de preview (randul devine FORM propriu)."""
account_id = require_login(request) account_id = require_login(request)
conn = get_connection() conn = get_connection()
try: try:
@@ -2400,11 +2395,11 @@ def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLRe
@router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse) @router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse)
async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse: async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse:
"""Alias web (US-001/US-002): persista override (mutatie pura) + re-randeaza DOAR randul. """Persista override (mutatie pura) + re-randeaza DOAR randul.
Statusul e rederivat prin `_resolve_row_for_preview` (H2). Swap pe rand + OOB Statusul e rederivat prin `_resolve_row_for_preview`. Swap pe rand + OOB contoare.
contoare (D-3.1). Daca raman erori de continut pe camp, randul ramane in editare Daca raman erori de continut pe camp, randul ramane in editare cu valorile pastrate
cu valorile pastrate si mesajul pe campul vinovat (D-2.1/D-2.2).""" si mesajul pe campul vinovat."""
account_id = require_login(request) account_id = require_login(request)
form = await request.form() form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or "")) verify_csrf(request, str(form.get("csrf_token") or ""))
@@ -2502,8 +2497,8 @@ async def web_confirma_import(
Replica logica din import_router.commit_import dar cu input din form HTML Replica logica din import_router.commit_import dar cu input din form HTML
si raspuns HTML (nu JSON). INSERT per-rand ON CONFLICT DO NOTHING (TOCTOU). si raspuns HTML (nu JSON). INSERT per-rand ON CONFLICT DO NOTHING (TOCTOU).
C8/OV-2: account_id din sesiune propagat consecvent la build_key si toate lookup-urile. account_id din sesiune propagat consecvent la build_key si toate lookup-urile.
C12: require_login — pe scrieri NICIODATA fallback cont 1 in prod. require_login — pe scrieri NICIODATA fallback cont 1 in prod.
""" """
account_id = require_login(request) account_id = require_login(request)
acct = account_or_default(account_id) acct = account_or_default(account_id)
@@ -2644,11 +2639,11 @@ async def web_confirma_import(
# Mapare operatii # Mapare operatii
mapping_meta = load_mapping_meta(conn, acct) mapping_meta = load_mapping_meta(conn, acct)
mapping_ops = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} mapping_ops = {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)
# Enqueue in tranzactie explicita (Issue 6) — INSERT ON CONFLICT DO NOTHING (TOCTOU) # Enqueue in tranzactie explicita — INSERT ON CONFLICT DO NOTHING (TOCTOU)
enqueued: list[dict] = [] enqueued: list[dict] = []
toctou: list[int] = [] toctou: list[int] = []
rows_for_hash: list[str] = [] rows_for_hash: list[str] = []
@@ -2696,7 +2691,7 @@ async def web_confirma_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 = item.get("override") or {} override = item.get("override") or {}
if override: if override:
mapped.update(override) mapped.update(override)
@@ -2729,7 +2724,7 @@ async def web_confirma_import(
if cur.rowcount == 0: if cur.rowcount == 0:
toctou.append(row_index) toctou.append(row_index)
else: else:
# US-010: telemetrie pentru itemii rezolvati prin regula text. # telemetrie pentru itemii rezolvati prin regula text.
_emite_text_rule_hits(conn, acct, int(cur.lastrowid), resolved_p) _emite_text_rule_hits(conn, acct, int(cur.lastrowid), resolved_p)
enqueued.append({"submission_id": cur.lastrowid, "row_index": row_index}) enqueued.append({"submission_id": cur.lastrowid, "row_index": row_index})
@@ -2740,7 +2735,7 @@ async def web_confirma_import(
n_enqueued = len(enqueued) n_enqueued = len(enqueued)
# Log atestare (Voce#9) # Log atestare
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 ""
@@ -2757,7 +2752,7 @@ async def web_confirma_import(
# Succes → bara de upload slim cu mesaj de confirmare. are_trimiteri=True: # Succes → bara de upload slim cu mesaj de confirmare. are_trimiteri=True:
# contul tocmai a pus randuri in coada -> bara ramane slim si dezvaluie # contul tocmai a pus randuri in coada -> bara ramane slim si dezvaluie
# sectiunea "Trimiterile tale" de pe Acasa (US-003/US-004). # sectiunea "Trimiterile tale" de pe Acasa.
toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else "" toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else ""
return templates.TemplateResponse("_upload.html", _ctx( return templates.TemplateResponse("_upload.html", _ctx(
request, request,
@@ -2773,8 +2768,8 @@ async def web_confirma_import(
# =========================================================================== # # =========================================================================== #
# US-007 — Sectiune "Contul meu": rotire cheie API + creds RAR din UI # # Sectiune "Contul meu": rotire cheie API + creds RAR din UI. #
# Rute web proprii scoped pe sesiune (C13: nu reutilizeaza /v1/conturi/rar-creds # Rute web proprii scoped pe sesiune (nu reutilizeaza /v1/conturi/rar-creds #
# care cere cheie API; sesiunea web e suficienta ca identitate). # # care cere cheie API; sesiunea web e suficienta ca identitate). #
# =========================================================================== # # =========================================================================== #
@@ -2846,10 +2841,9 @@ def integrare_test_cheie(
) -> HTMLResponse: ) -> HTMLResponse:
"""Verifica cheia API lipita de utilizator — scoped pe contul sesiunii. """Verifica cheia API lipita de utilizator — scoped pe contul sesiunii.
US-004 (PRD Etapa 5): permite utilizatorului sa confirme ca o cheie copiata Permite utilizatorului sa confirme ca o cheie copiata din generatorul de exemple
din generatorul de exemple corespunde contului sau, fara efecte secundare corespunde contului sau, fara efecte secundare (fara creare/rotire). Cheie goala,
(fara creare/rotire). Cheie goala, invalida sau a altui cont -> mesaj de invalida sau a altui cont -> mesaj de eroare neutru (fara eco al cheii in raspuns).
eroare neutru (fara eco al cheii in raspuns).
""" """
account_id = require_login(request) account_id = require_login(request)
verify_csrf(request, csrf_token) verify_csrf(request, csrf_token)
@@ -2901,7 +2895,7 @@ def cont_rar_creds(
rar_parola: str = Form(""), rar_parola: str = Form(""),
csrf_token: str | None = Form(None), csrf_token: str | None = Form(None),
) -> HTMLResponse: ) -> HTMLResponse:
"""Seteaza creds RAR per cont din sesiune (ruta web proprie, C13). """Seteaza creds RAR per cont din sesiune (ruta web proprie).
Camp parola NICIODATA re-pus in value= la re-randare. Camp parola NICIODATA re-pus in value= la re-randare.
Validare minima: email si parola negoale. Validare minima: email si parola negoale.

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

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);">
@@ -68,8 +66,4 @@
</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,11 +18,6 @@
<!-- 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. #}
{# US-010: sectiunea de ajutor (details.ajutor-mapari) eliminata.
Empty-state „Nicio operatie nemapata" eliminat — sectiunea ramane goala (fara text). #}
<h2 style="font-size:15px; margin:0 0 8px;">De rezolvat</h2> <h2 style="font-size:15px; margin:0 0 8px;">De rezolvat</h2>
{% if pending %} {% if pending %}
@@ -102,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"
@@ -150,7 +143,7 @@
{{ 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">
{# US-011: butoane icon mereu vizibile (fara kebab). SVG aria-hidden; aria-label pe buton. {# Butoane icon mereu vizibile (fara kebab). SVG aria-hidden; aria-label pe buton.
data-dirty-form e citit de JS din base.html: la schimbarea select-ului din acelasi rand, data-dirty-form e citit de JS din base.html: la schimbarea select-ului din acelasi rand,
JS adauga clasa "dirty" pe butonul de salvare (fundal --accent = modificari nesalvate). #} JS adauga clasa "dirty" pe butonul de salvare (fundal --accent = modificari nesalvate). #}
<button type="submit" form="map-salv-{{ loop.index }}" <button type="submit" form="map-salv-{{ loop.index }}"
@@ -182,7 +175,6 @@
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- Sectiunea 3: Reguli automate pe text (operation_text_rules) --> <!-- Sectiunea 3: Reguli automate pe text (operation_text_rules) -->
<!-- US-010: mutata pe pozitia 3 (inainte de Formate de coloane) -->
<!-- ============================================================ --> <!-- ============================================================ -->
<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>
@@ -266,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>
@@ -279,7 +271,6 @@
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- Sectiunea 4: Formate de coloane salvate (column_mappings) --> <!-- Sectiunea 4: Formate de coloane salvate (column_mappings) -->
<!-- US-010: mutata pe pozitia 4 (dupa Reguli automate) -->
<!-- ============================================================ --> <!-- ============================================================ -->
<div class="card"> <div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">Formate de coloane salvate</h2> <h2 style="font-size:15px; margin:0 0 12px;">Formate de coloane salvate</h2>

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

@@ -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

@@ -1,6 +1,6 @@
{# {#
OOB: actualizeaza inputul id="f-page" din #filtre-trimiteri (US-004 L2). OOB: actualizeaza inputul id="f-page" din #filtre-trimiteri.
Poll-ul de 15s (hx-include="#filtre-trimiteri") preia automat pagina curenta. Reincarcarea (hx-include="#filtre-trimiteri") preia automat pagina curenta.
Elementul OOB e extras din continutul normal de HTMX inainte de swap in #submissions-wrap. 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"> <input type="hidden" id="f-page" name="page" value="{{ page | default(1) }}" hx-swap-oob="true">
@@ -13,7 +13,7 @@
<span id="trimiteri-versiune" data-v="{{ versiune_trimiteri | default('') }}" hidden></span> <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"
@@ -43,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 }}"
@@ -65,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>
@@ -75,14 +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 != '—' %}
{# US-005: VIN pe rand separat sub nr (element block, nu span inline) #} {# VIN pe rand separat sub nr (element block, nu span inline) #}
<div class="muted" style="font-size:12px;">{{ r.prez.vin_scurt }}</div> <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 %}
@@ -100,7 +99,7 @@
</form> </form>
{# {#
Paginare numerotata (US-004 PRD 5.10). Paginare numerotata.
Afisata doar cand exista mai mult de o pagina. Afisata doar cand exista mai mult de o pagina.
Fiecare link pastreaza filtrele curente (status, vehicul, data_de, data_pana). Fiecare link pastreaza filtrele curente (status, vehicul, data_de, data_pana).
Pagina curenta: aria-current="page" (semantic). Pagina curenta: aria-current="page" (semantic).

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,7 +18,7 @@
<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 — DOAR in read-only (US-008). {# === Bloc eroare blocanta — DOAR in read-only.
In editare, cardul 3-niveluri e inlocuit cu: erori per-camp in macro `camp` 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). === #} (text simplu .s-error) + rezumat top-of-form pentru erori fara camp (mai jos). === #}
{% if not editabil and erori_3n %} {% if not editabil and erori_3n %}
@@ -28,7 +27,7 @@
</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 %}
@@ -78,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 %}
@@ -90,7 +89,7 @@
{% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div> {% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div>
{% endif %} {% endif %}
{# US-008 (M6): erori fara camp (field None) nu dispar silentios in editare — {# Erori fara camp (field None) nu dispar silentios in editare —
cardul 3n e ascuns, deci adaugam un rezumat simplu top-of-form. cardul 3n e ascuns, deci adaugam un rezumat simplu top-of-form.
Erori cu camp raman afisate per-camp de macro-ul `camp` de mai jos. #} Erori cu camp raman afisate per-camp de macro-ul `camp` de mai jos. #}
{% for e in erori_3n if not e.field %} {% for e in erori_3n if not e.field %}
@@ -114,7 +113,7 @@
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 }}">
{# US-006: select cod RAR pe stari editabile (needs_data/needs_mapping), cu nomenclator. {# Select cod RAR pe stari editabile (needs_data/needs_mapping), cu nomenclator.
Read-only pe sent/sending/queued/error (nomenclator_rar gol → ramura else). #} Read-only pe sent/sending/queued/error (nomenclator_rar gol → ramura else). #}
{% if nomenclator_rar %} {% if nomenclator_rar %}
<div style="margin:0 0 12px;"> <div style="margin:0 0 12px;">
@@ -133,15 +132,15 @@
</select> </select>
</div> </div>
{% else %} {% else %}
{# Operatie + cod RAR read-only deasupra campurilor (R9, fara eticheta „Cod RAR"). #} {# 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 %} {% endif %}
{# US-007: operatie service (cod intern + denumire venita prin API/import), distinct de {# Operatie service (cod intern + denumire venita prin API/import), distinct de
operatia RAR mapata. Conventie US-002: op_service_cod="" cand lipseste → randul absent. #} operatia RAR mapata. op_service_cod="" cand lipseste → randul absent. #}
{% if prez.op_service_cod %} {% if prez.op_service_cod %}
<div style="margin:0 0 12px;"> <div style="margin:0 0 12px;">
<div class="muted" style="font-size:12px;">Operatie service</div> <div class="muted" style="font-size:12px;">Operatie service</div>
@@ -160,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>
@@ -177,8 +176,8 @@
<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>
{# US-007: operatie service (cod intern + denumire), distinct de operatia RAR. {# Operatie service (cod intern + denumire), distinct de operatia RAR.
Conventie US-002: op_service_cod="" cand lipseste → randul absent (fara "—"). #} op_service_cod="" cand lipseste → randul absent (fara "—"). #}
{% if prez.op_service_cod %} {% if prez.op_service_cod %}
<div><div class="muted" style="font-size:12px;">Operatie service</div> <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> <div>{{ prez.op_service_cod }}{% if prez.op_service_denumire %} — {{ prez.op_service_denumire }}{% endif %}</div></div>
@@ -188,17 +187,17 @@
</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 pentru #} {# Error -> buton primar „Re-pune in coada" pe /repune (error nu e editabil pentru #}
{# campuri vehicul, dar US-006b permite schimbarea cod_prestatie prin acelasi formular). #} {# 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 }}">
{# US-006b: select cod_prestatie optional in formularul /repune (doar pentru error). #} {# Select cod_prestatie optional in formularul /repune (doar pentru error). #}
{% if nomenclator_rar %} {% if nomenclator_rar %}
<label for="cod-rar-error-{{ id }}" style="display:block; font-size:12px; color:var(--muted); margin-bottom:4px;"> <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) Operatie RAR (optional — schimba codul si re-pune)
@@ -219,7 +218,7 @@
</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"
@@ -235,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;">
@@ -257,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,8 +14,8 @@
htmx.config.useTemplateFragments = true; htmx.config.useTemplateFragments = true;
</script> </script>
<script> <script>
// Anti-FOUC (US-001 PRD 5.3, extins US-014 PRD 5.10): citeste preferinta tema din // Anti-FOUC: citeste preferinta tema din localStorage inainte de primul
// localStorage inainte de primul paint; seteaza data-theme pe <html> sincron, fara blink. // paint; seteaza data-theme pe <html> sincron, fara blink.
// Cunoaste toate cele 4 teme: light/dark/petrol/auto. Valoare legacy/necunoscuta -> auto. // 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). // 'auto' se rezolva la 'light' sau 'dark' dupa prefers-color-scheme (fara blink).
(function() { (function() {
@@ -33,12 +33,9 @@
})(); })();
</script> </script>
<style> <style>
/* US-013 (PRD 5.10): IBM Plex Sans + Mono self-hosted (latin-ext pentru diacritice romanesti). /* IBM Plex Sans + Mono self-hosted (latin-ext pentru diacritice romanesti).
font-display:swap permite text vizibil inainte de incarcare (FOUT system-ui->IBM Plex). font-display:swap permite text vizibil inainte de incarcare (FOUT system-ui->IBM Plex);
FOUT pe tabular-nums: IBM Plex Sans are metrici apropiate de system-ui; reflow-ul vizibil reflow-ul vizibil pe VIN/coduri e acceptat explicit. */
pe VIN/coduri e acceptat explicit — fontul se incarca din /static/ (acelasi origin).
IBM Plex Sans/Mono self-host, subset latin + latin-ext de pe fontsource
(@fontsource/ibm-plex-sans + @fontsource/ibm-plex-mono, v5.0.8), woff2 valide. */
@font-face { @font-face {
font-family: "IBM Plex Sans"; font-family: "IBM Plex Sans";
font-style: normal; font-style: normal;
@@ -103,38 +100,32 @@
src: url("/static/fonts/IBMPlexMono-Regular-latin.woff2") format("woff2"); 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; 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 conform DESIGN.md */ /* Paleta dark (default) — accent azur ROMFAST */
:root { --bg:#0f1218; --card:#181c24; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36; :root { --bg:#0f1218; --card:#181c24; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36;
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6; } --ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6; }
/* Paleta light — accent azur inchis pentru contrast AA pe alb (#1F66C9: 5.51:1 pe alb) */ /* 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; [data-theme="light"] { --bg:#f5f7fa; --card:#ffffff; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea;
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#1F66C9; } --ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#1F66C9; }
/* Paleta Petrol (US-014) — tema intunecata alternativa, accent teal #0E7C7B. /* Paleta Petrol — tema intunecata alternativa, accent teal #0E7C7B.
Wordmark-ul FAST #2E74D6 coexista armonios: ambele sunt reci/saturate, contrast AA pe --card #161e20. */ 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; [data-theme="petrol"] { --bg:#0e1416; --card:#161e20; --ink:#e6e9ef; --muted:#8b93a7; --line:#232c2e;
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#0E7C7B; } --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 "IBM Plex Sans",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; }
/* US-012c (PRD 5.10): grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). */ /* Grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). */
header { padding:16px 24px; border-bottom:1px solid var(--line); 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; } display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:8px; min-height:92px; }
.header-left { display:flex; align-items:center; } .header-left { display:flex; align-items:center; }
.header-center { display:flex; flex-direction:column; align-items:center; text-align: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; } .header-right { display:flex; align-items:center; justify-content:flex-end; gap:8px; }
/* US-012c: logo PNG ROMFAST in header-left (brand top-left ca pe romfast.ro). /* Logo ROMFAST aliniat stanga; transparent, ok pe dark/light/petrol fara filtre de culoare. */
32px inaltime — usor mai mare decat in header-center (28px) pentru vizibilitate ca brand anchor.
margin:0 — aliniat stanga, NU centrat (era `margin:3px auto 0` cand era sub titlu).
Logo transparent: ok pe dark/light/petrol fara filtre de culoare. */
/* Logo ROMFAST la dimensiunea de pe romfast.ro (~60px inaltime), aliniat stanga. */
.brand-logo { height:60px; width:auto; display:block; margin:0; } .brand-logo { height:60px; width:auto; display:block; margin:0; }
/* Env badge mic sub titlu in header-center (US-012c): nu mai echilibreaza optic dreapta
(logo-ul face asta), ci identifica mediul langa titlu. Pastrat mic, color:var(--muted). */
.header-center .env { font-size:11px; margin-top:2px; } .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; }
@@ -210,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; }
@@ -224,7 +215,7 @@
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));
@@ -237,13 +228,13 @@
.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); }
/* US-011: variante icon-btn — dirty (modificari nesalvate) + danger (destructiv) */ /* Variante icon-btn — dirty (modificari nesalvate) + danger (destructiv) */
.icon-btn.dirty { background:var(--accent); color:#fff; border-color:var(--accent); } .icon-btn.dirty { background:var(--accent); color:#fff; border-color:var(--accent); }
.icon-btn.dirty:hover { filter:brightness(0.9); } .icon-btn.dirty:hover { filter:brightness(0.9); }
.icon-btn.danger { color:var(--err); border-color:var(--err); } .icon-btn.danger { color:var(--err); border-color:var(--err); }
@@ -260,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;
@@ -288,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; }
@@ -302,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:"IBM Plex Mono",ui-monospace,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); }
@@ -321,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; }
@@ -341,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 */
@@ -366,7 +356,7 @@
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: pe mobil trece de la grid la flex wrap. /* Header + nav colapsate: pe mobil trece de la grid la flex wrap.
@@ -383,9 +373,9 @@
.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; }
@@ -417,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; }
@@ -441,16 +431,14 @@
</style> </style>
</head> </head>
<body> <body>
{# US-012c (PRD 5.10): grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). {# Grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). #}
Decizie env badge: mutat in header-center sub <h1> (mic, color:muted) — nu suprapune logo-ul
si pastreaza centrarea optica a titlului in coloana auto. #}
<header> <header>
{# Celula stanga: logo ROMFAST (US-012c: brand top-left ca pe romfast.ro) #} {# Celula stanga: logo ROMFAST #}
<div class="header-left"> <div class="header-left">
{# US-012b/c: logo PNG real, 288x175 RGBA transparent — ok pe toate temele fara filtre. #} {# Logo PNG real, RGBA transparent — ok pe toate temele fara filtre. #}
<img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo"> <img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
</div> </div>
{# Celula centru: titlu + badge env mic (US-012c: env mutat din header-left aici) #} {# Celula centru: titlu + badge env mic #}
<div class="header-center"> <div class="header-center">
<h1>Gateway RAR AUTOPASS</h1> <h1>Gateway RAR AUTOPASS</h1>
<span class="env">{{ rar_env }}</span> <span class="env">{{ rar_env }}</span>
@@ -462,14 +450,14 @@
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>
{# US-009 (PRD 5.10): Mapari mutat din tab-bar in meniu, cu badge needs_mapping. #} {# Mapari, cu badge needs_mapping. #}
{% set _mapari_badge = (badges.mapari if (badges is defined and badges and badges.mapari) else 0) %} {% set _mapari_badge = (badges.mapari if (badges is defined and badges and badges.mapari) else 0) %}
<a role="menuitem" href="/?tab=mapari">Mapari{% if _mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ _mapari_badge }}</span>{% endif %}</a> <a role="menuitem" href="/?tab=mapari">Mapari{% if _mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ _mapari_badge }}</span>{% endif %}</a>
<hr> <hr>
@@ -488,14 +476,14 @@
{% endif %} {% endif %}
</div> </div>
</header> </header>
{# aria-live pentru anuntarea schimbarilor de tema (US-014, accesibilitate) #} {# aria-live pentru anuntarea schimbarilor de tema (accesibilitate) #}
<span id="tema-live" role="status" aria-live="polite" <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> 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>
@@ -505,7 +493,7 @@
</div> </div>
</div> </div>
<script> <script>
// Comutator tema ciclic (US-014 PRD 5.10): click cicleaza Light->Dark->Petrol->Auto. // Comutator tema ciclic: click cicleaza Light->Dark->Petrol->Auto.
// Separare init (sincronizare iconita/label) de persistenta (doar la click explicit). // Separare init (sincronizare iconita/label) de persistenta (doar la click explicit).
// 'auto' se rezolva la paint prin anti-FOUC; aici setam data-theme rezolvat. // 'auto' se rezolva la paint prin anti-FOUC; aici setam data-theme rezolvat.
(function() { (function() {
@@ -528,7 +516,7 @@
var s = VALID[stored] ? stored : 'auto'; var s = VALID[stored] ? stored : 'auto';
btn.innerHTML = ICONS[s]; btn.innerHTML = ICONS[s];
btn.setAttribute('aria-label', 'Tema: ' + LABELS[s] + ', apasa pentru ' + NEXT[s]); btn.setAttribute('aria-label', 'Tema: ' + LABELS[s] + ', apasa pentru ' + NEXT[s]);
btn.title = LABELS[s]; // US-014b: doar numele temei (ex. "Petrol"), nu ciclul intreg btn.title = LABELS[s]; // doar numele temei (ex. "Petrol"), nu ciclul intreg
} }
function _setTheme(t) { function _setTheme(t) {
document.documentElement.setAttribute('data-theme', _resolved(t)); document.documentElement.setAttribute('data-theme', _resolved(t));
@@ -547,7 +535,7 @@
})(); })();
</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');
@@ -624,7 +612,7 @@
})(); })();
</script> </script>
<script> <script>
// US-011: dirty state pentru butoanele de salvare din tabelele de mapari. // Dirty state pentru butoanele de salvare din tabelele de mapari.
// Cand utilizatorul schimba un select dintr-un form de mapare, butonul de salvare // Cand utilizatorul schimba un select dintr-un form de mapare, butonul de salvare
// legat prin data-dirty-form devine evidentiat (clasa "dirty" → fundal --accent). // legat prin data-dirty-form devine evidentiat (clasa "dirty" → fundal --accent).
// Starea "dirty" e efemera per-render: un swap outerHTML o reseteaza automat. // Starea "dirty" e efemera per-render: un swap outerHTML o reseteaza automat.
@@ -700,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;
@@ -721,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();
@@ -755,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.
@@ -778,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;
@@ -796,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(); });

View File

@@ -1,11 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
{# US-009 (PRD 5.10): tab-bar-ul Acasa/Mapari a fost eliminat. Mapari s-a mutat in meniul
hamburger (#cont-menu in base.html). Acasa e continutul principal direct — nicio schela ARIA
role="tablist"/"tab"/"tabpanel" orfana. Rutele /_fragments/* si deep-link-urile ?tab=
raman valide (navigare prin meniu → full page reload). #}
<!-- Bara de status: mereu vizibila --> <!-- Bara de status: mereu vizibila -->
<div id="status-bar" class="status-bar card" <div id="status-bar" class="status-bar card"
hx-get="/_fragments/status" hx-get="/_fragments/status"

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

@@ -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)"):]