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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
"""Extragere payload submission -> campuri afisabile (US-003, PRD 3.5).
"""Extragere payload submission -> campuri afisabile.
Helper PUR partajat intre canalul web (dashboard Trimiteri) si canalul API
(`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua (decizie
@@ -115,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 = _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).
# Conventie goala: aceste campuri NOI intorc "" (string gol) cand lipsesc — NU EMPTY="—".
# Motivul: US-007 decide sa nu afiseze randul deloc (vs afisaj gol), testând `!= ""`.
# Conventie goala: aceste campuri intorc "" (string gol) cand lipsesc — NU EMPTY="—".
# Motivul: apelantul decide sa nu afiseze randul deloc (vs afisaj gol), testând `!= ""`.
# Campurile vechi (vehicul_nr, vin, operatie etc.) pastreaza conventia EMPTY="—".
op_service_cod = _clean_str(item.get("cod_op_service"))
# op_service_denumire e relevant doar cand exista un cod de operatie de service;
@@ -134,7 +134,7 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
"odometru": odo or EMPTY,
"cod": cod or EMPTY,
"cod_rar": cod_rar or EMPTY,
# 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_denumire": op_service_denumire,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,11 @@
"""Dashboard Jinja2 + HTMX (server-rendered, zero build).
Schelet cu stari explicite: empty (coada goala), banner alerta blocate,
worker viu/mort, ultimul login RAR. Editor mapari + browser nomenclator +
export CSV + stare "RAR indisponibil" = de adaugat (plan.md sect. 4 + design-review).
worker viu/mort, ultimul login RAR. Editor mapari + browser nomenclator.
U5 — Rute web pentru import fisier (upload → mapare coloane → preview → confirma → trimite).
Consuma endpointurile backend din import_router (helper-e interne) fara a le modifica.
Toate rutele /_import/* returneaza fragmente HTML targetate pe #import-section prin HTMX.
Rute web pentru import fisier (upload → mapare coloane → preview → confirma → trimite).
Consuma helper-e interne din import_router fara a le modifica. Toate rutele /_import/*
returneaza fragmente HTML targetate pe #import-section prin HTMX.
"""
from __future__ import annotations
@@ -83,12 +82,12 @@ from ..mapping import (
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()]
router = APIRouter(tags=["web"])
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
_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.
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}
@@ -161,15 +160,14 @@ def _rar_state(hb, worker_alive: bool) -> str:
return "indisponibil?" if age > 108000 else "ok"
# US-002: "import" nu mai e tab separat — importul traieste pe Acasa. ?tab=import
# cade pe Acasa (tab invalid -> fallback "acasa" in dashboard()), fara 404.
# US-003 (3.6): "coada" (Trimiteri) nu mai e tab — Trimiterile sunt sectiune pe Acasa.
# ?tab=coada cade tot pe Acasa (fallback), fara 404, fara fragment orfan.
# "import" si "coada" nu mai sunt tab-uri separate — importul si Trimiterile sunt
# sectiuni pe Acasa. ?tab=import / ?tab=coada cad pe Acasa (fallback in dashboard()),
# fara 404 si fara fragment orfan.
_TABS_VALIDE = {"acasa", "mapari", "cont", "nomenclator", "integrare", "jurnal"}
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).
"""
@@ -197,8 +195,8 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
).fetchone()
are_cheie_folosita = row_key is not None
# US-003 (3.6): contorul de atentie (blocate) se reflecta in heading-ul
# sectiunii "Trimiterile tale" de pe Acasa, nu pe un tab disparut.
# Contorul de atentie (blocate) se reflecta in heading-ul sectiunii
# "Trimiterile tale" de pe Acasa.
counts = _status_counts(conn, account_id)
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),
# Semnatura datelor: nudge-ul "Date noi" o compara la fiecare poll usor.
"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),
}
@@ -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:
"""Randeaza panoul Acasa ca string HTML.
`status` (US-014/T13): deep-link `?tab=acasa&status=error` pre-selecteaza filtrul de
stare in sectiunea Trimiteri, astfel ca lista se incarca direct filtrata (nu dead-end).
`status`: deep-link `?tab=acasa&status=error` pre-selecteaza filtrul de stare in
sectiunea Trimiteri, astfel ca lista se incarca direct filtrata (nu dead-end).
"""
if conn is None:
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:
"""US-003 (3.6): "coada" nu mai e panou propriu — serveste continutul Acasa
(Trimiterile sunt sectiune pe Acasa). Pastrat ca alias pentru deep-link/bookmark vechi."""
""""coada" nu mai e panou propriu — serveste continutul Acasa (Trimiterile sunt
sectiune pe Acasa). Pastrat ca alias pentru deep-link/bookmark vechi."""
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,
cont: str | None = None, page: int = 0,
) -> 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
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)
tip = (tip or "").strip() or None
@@ -397,7 +395,7 @@ def _jurnal_context(
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))
@@ -424,20 +422,20 @@ def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str, sta
@router.get("/", response_class=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
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
Trimiteri de pe Acasa (deep-link din banner-ul "Necesita atentia ta").
Tab invalid -> fallback la 'acasa'. `?status=` pre-filtreaza lista Trimiteri de
pe Acasa (deep-link din banner-ul "Necesita atentia ta").
"""
account_id = require_login(request)
active_tab = tab if tab in _TABS_VALIDE else "acasa"
conn = get_connection()
try:
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
# (fost badge "coada") se reflecta acum in heading-ul sectiunii Trimiteri (US-003).
# Badge contoare pe tab-uri: needs_mapping -> Mapari. Blocatele se reflecta in
# heading-ul sectiunii Trimiteri.
counts = _status_counts(conn, account_id)
badges = {
"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)
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)
conn = get_connection()
try:
@@ -472,16 +470,16 @@ def fragment_acasa(request: Request) -> HTMLResponse:
@router.get("/_fragments/import", response_class=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)
return templates.TemplateResponse("_upload.html", _ctx(request))
@router.get("/_fragments/coada", response_class=HTMLResponse)
def fragment_coada(request: Request) -> HTMLResponse:
"""US-003 (3.6): "coada" nu mai are fragment propriu. Serveste continutul Acasa
(Trimiterile sunt sectiune permanenta pe Acasa) — evita un fragment `_coada.html`
orfan din bookmark-uri/HTMX vechi. Nu da 404."""
""""coada" nu mai are fragment propriu. Serveste continutul Acasa (Trimiterile sunt
sectiune permanenta pe Acasa) — evita un fragment `_coada.html` orfan din
bookmark-uri/HTMX vechi. Nu da 404."""
account_id = require_login(request)
conn = get_connection()
try:
@@ -528,7 +526,7 @@ def fragment_jurnal(
cont: str | None = None,
page: int = 0,
) -> 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.
"""
@@ -563,8 +561,8 @@ def fragment_banner(request: Request) -> HTMLResponse:
def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]:
"""Construieste lista [(eticheta, n), ...] pentru starile blocate cu n > 0.
Ordinea: needs_mapping, needs_data, error — aceeasi ca in PRD.
Returneaza lista goala daca nu exista nicio stare blocata.
Ordinea: needs_mapping, needs_data, error. Returneaza lista goala daca nu
exista nicio stare blocata.
"""
rezultat = []
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]:
"""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.
Returneza lista goala daca nu exista nicio stare blocata.
Reutilizeaza contoarele deja calculate din _status_counts (fara PII/VIN per rand).
Returneaza 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,
# pentru ca s-needs_mapping in base.html e tot --err (incorect pentru pill).
PILL_DEFS = [
@@ -598,7 +595,7 @@ def _pills_categorii(counts: dict[str, int]) -> list[dict]:
@router.get("/_fragments/status", response_class=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
autentificare, contorii de coada si defalcarea blocatelor pe motiv.
@@ -623,7 +620,7 @@ def fragment_status(request: Request) -> HTMLResponse:
"request": request,
"worker_lbl": worker_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,
"rar_ok": rar_ok,
"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
(ex. '2026-06-20 14:35:07' sau '2026-06-20T14:35:07') — extrage portiunea de data
fara a exclude timestamp-urile (bug-ul fix US-001: _is_iso_date cerea len==10).
Valori care nu incep cu o data ISO valida (ex. '05.12.2024') intorc None si
sunt excluse din filtru — comportament actual pastrat.
fara a exclude timestamp-urile. Valori care nu incep cu o data ISO valida
(ex. '05.12.2024') intorc None si sunt excluse din filtru.
"""
s = str(value or "").strip()
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
# 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")
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
`eticheta_scurta` cand motivul e gol — NU re-parseaza `rar_error` (R1: DRY, fara
al 3-lea decoder). Codul BRUT de catalog ramane doar pentru modal, nu pe rand.
`eticheta_scurta` cand motivul e gol — NU re-parseaza `rar_error` (DRY, fara al
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_*.
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:
"""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"])
motiv = motiv_uman(r["status"], r["rar_error"])
return {
"id": r["id"],
"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_text": eticheta[0],
"stare_css": eticheta[2],
@@ -708,15 +704,15 @@ def _submission_row_view(r) -> dict:
"id_prezentare": r["id_prezentare"],
"updated_at": format_data_rar(r["updated_at"]),
"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),
# US-011: randurile blocate (error/needs_data/needs_mapping) sunt selectabile
# pentru stergere bulk; sent/sending/queued raman read-only (fara checkbox).
# randurile blocate (error/needs_data/needs_mapping) sunt selectabile pentru
# stergere bulk; sent/sending/queued raman read-only (fara checkbox).
"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)
@@ -728,9 +724,9 @@ def fragment_submissions(
data_pana: str | None = None,
page: int = 1,
) -> 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
- 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).
@@ -756,8 +752,8 @@ def fragment_submissions(
where_sql = " AND ".join(where)
if filtru_python:
# Calea B: fetch-all, filtreaza in Python, slice (US-004 H1)
# FARA LIMIT — altfel paginile >8 ar disparea silentios (bug PRD H1)
# Calea B: fetch-all, filtreaza in Python, slice.
# FARA LIMIT — altfel paginile >8 ar disparea silentios.
rows_db = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC",
@@ -773,7 +769,7 @@ def fragment_submissions(
if vehicul_q not in hay:
continue
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"])
if d_prefix is None:
continue
@@ -785,7 +781,7 @@ def fragment_submissions(
total = len(view_all)
pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1
page = max(1, min(page, pages)) # clamp H2
page = max(1, min(page, pages)) # clamp la nr. de pagini
offset = (page - 1) * _PAGE_SIZE
view = view_all[offset:offset + _PAGE_SIZE]
@@ -796,7 +792,7 @@ def fragment_submissions(
).fetchone()[0]
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
rows_db = conn.execute(
@@ -815,13 +811,13 @@ def fragment_submissions(
"rows": view,
"filtru_activ": filtru_activ,
"csrf_token": get_csrf_token(request),
# Paginare (US-004)
# Paginare
"total": total,
"page": page,
"pages": pages,
"page_start": page_start,
"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_vehicul": vehicul_q or "",
"f_data_de": data_de or "",
@@ -835,17 +831,17 @@ def fragment_submissions(
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")
# US-006b: stari cu select editabil cod_prestatie (superset al _CORECTABILE: error
# primeste select in formularul /repune, nu in /corecteaza — fara schimbare de vehicle fields).
# Stari cu select editabil cod_prestatie (superset al _CORECTABILE: error primeste
# select in formularul /repune, nu in /corecteaza — fara schimbare de vehicle fields).
_EDITABILE_OP = ("needs_data", "needs_mapping", "error")
# Stari gestionabile prin lifecycle web (US-011): sterge / re-pune in coada.
# Stari gestionabile prin lifecycle web: sterge / re-pune in coada.
_GESTIONABILE_WEB = ("error", "needs_data", "needs_mapping")
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)
rows = conn.execute(
"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:
"""Valori brute pentru prefill-ul formularului de corectie (US-010)."""
"""Valori brute pentru prefill-ul formularului de corectie."""
try:
data = json.loads(payload_json) if payload_json else {}
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]:
"""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,
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.
`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"])
nemapate_inline: 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] = []
if conn is not None and row["status"] == "needs_mapping":
# 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)
nomenclator = _nomenclator_complet if nemapate_inline else []
# US-006/US-006b: nomenclator pentru selectul cod_prestatie — needs_data/needs_mapping (in
# formularul /corecteaza) + error (in formularul /repune). Refoloseste _nomenclator_complet
# daca e deja incarcat (needs_mapping), altfel incarca fresh.
# Nomenclator pentru selectul cod_prestatie — needs_data/needs_mapping (in formularul
# /corecteaza) + error (in formularul /repune). Refoloseste _nomenclator_complet daca
# e deja incarcat (needs_mapping), altfel incarca fresh.
nomenclator_rar: list[dict] = []
if conn is not None and row["status"] in _EDITABILE_OP:
nomenclator_rar = _nomenclator_complet if _nomenclator_complet else load_nomenclator(conn)
# US-006: cod_prestatie curent din prima prestatie (pentru pre-selectare in select)
# cod_prestatie curent din prima prestatie (pentru pre-selectare in select)
cod_prestatie_curent = ""
try:
_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"]),
"updated_at": format_data_rar(row["updated_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,
# 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,
# PRD 5.7: mapare inline (operatii nemapate ale acestui rand + nomenclator)
# mapare inline (operatii nemapate ale acestui rand + nomenclator)
"nemapate_inline": nemapate_inline,
"nomenclator": nomenclator,
# US-006: select cod_prestatie pentru stari editabile
# select cod_prestatie pentru stari editabile
"nomenclator_rar": nomenclator_rar,
"cod_prestatie_curent": cod_prestatie_curent,
"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):
"""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)
return conn.execute(
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()
# Campuri afisate in detaliul trimiterii (panou dedicat US-004). payload_json e
# plaintext si se foloseste doar pentru campurile derivate (prezentare_din_payload).
# Campuri afisate in detaliul trimiterii. payload_json e plaintext si se foloseste
# doar pentru campurile derivate (prezentare_din_payload).
@router.get("/_fragments/trimitere/{submission_id}", response_class=HTMLResponse)
def fragment_trimitere_detaliu(request: Request, submission_id: int) -> HTMLResponse:
"""Detaliu complet al unei trimiteri, in panou dedicat (#trimitere-detaliu).
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)
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)
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
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() != "":
content[camp] = val.strip()
# US-006: injectare cod_prestatie din form INAINTE de resolve_prestatii.
# Oglindeste validarea din post_mapeaza_inline (nomenclator check). Codul nou
# e injectat in prima prestatie (index 0); build_key il include in hash (CLAUDE.md
# invariant "build_key hashuieste cod_prestatie, idempotency.py:34").
# Injectare cod_prestatie din form INAINTE de resolve_prestatii. Oglindeste
# validarea din post_mapeaza_inline (nomenclator check). Codul nou e injectat in
# prima prestatie (index 0); build_key il include in hash.
_cod_raw = form.get("cod_prestatie")
cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "")
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)
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)
# 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",
_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
# se inchide (inchideModal). After-settle ca inchiderea sa urmeze swap-ul fragmentului.
# Pe succes, lista se reincarca (trimiteriChanged) si modalul se inchide
# (inchideModal). After-settle ca inchiderea sa urmeze swap-ul fragmentului.
resp.headers["HX-Trigger-After-Settle"] = "trimiteriChanged, inchideModal"
return resp
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 #
# Peste helper-ul US-009 (submissions_admin). CSRF enforce; scoped pe sesiune. #
# Lifecycle trimiteri blocate din dashboard: sterge / re-pune in coada. #
# Peste helper-ul submissions_admin. CSRF enforce; scoped pe sesiune. #
# =========================================================================== #
@router.post("/trimitere/{submission_id}/repune", response_class=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.
US-006b: daca randul e in starea `error` si formularul contine `cod_prestatie`,
actualizeaza codul in payload, recalculeaza cheia de idempotency si re-pun in coada
direct (fara `requeue_submission`, care nu actualizeaza cheia). Scoped pe sesiune
(404 cross-account/inexistent, 409 sent/sending). Re-randeaza panoul de detaliu cu
starea noua + nudge `trimiteriChanged` pentru lista.
Daca randul e in starea `error` si formularul contine `cod_prestatie`, actualizeaza
codul in payload, recalculeaza cheia de idempotency si re-pune in coada direct (fara
`requeue_submission`, care nu actualizeaza cheia). Scoped pe sesiune (404
cross-account/inexistent, 409 sent/sending). Re-randeaza panoul de detaliu cu starea
noua + nudge `trimiteriChanged` pentru lista.
"""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
conn = get_connection()
try:
# US-006b: prelucrare cod_prestatie pentru starea error (inaintea requeue_submission
# Prelucrare cod_prestatie pentru starea error (inaintea requeue_submission
# standard, care nu actualizeaza cheia de idempotency).
_cod_raw = form.get("cod_prestatie")
cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "")
@@ -1398,7 +1393,7 @@ async def post_sterge_trimitere(request: Request, submission_id: int) -> HTMLRes
resp = HTMLResponse(
'<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"
return resp
finally:
@@ -1410,7 +1405,7 @@ async def post_sterge_bulk(request: Request) -> HTMLResponse:
"""Sterge in bloc trimiterile selectate (doar blocate, scoped pe sesiune).
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)
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]:
"""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)
rows = conn.execute(
"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]:
"""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.
"""
@@ -1507,7 +1502,7 @@ def _render_mapari(
def fragment_mapari(request: Request) -> HTMLResponse:
"""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)
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. #
# =========================================================================== #
@@ -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.#
# =========================================================================== #
@@ -1628,7 +1623,7 @@ def post_salveaza_regula_text(
Scoped pe contul sesiunii (save_text_rule foloseste account_or_default(sesiune)).
Valideaza cod_prestatie fata de nomenclator INAINTE de save (cod necunoscut ->
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)
verify_csrf(request, csrf_token)
@@ -1647,7 +1642,7 @@ def post_salveaza_regula_text(
request, conn, account_id,
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
# regulile curente, ca pattern-ul nou sa nu se compare cu sine.
overlap = text_rules_overlap(pat, load_text_rules(conn, account_id))
@@ -1696,7 +1691,7 @@ def post_preview_regula_text(
pattern: str = Form(""),
csrf_token: str | None = Form(None),
) -> 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
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). #
# =========================================================================== #
@@ -1879,7 +1874,7 @@ def _web_compute_preview(
if not raw_rows_db:
return "Niciun rand in batch."
# Decripteaza randurile + override-urile editate (3.6)
# Decripteaza randurile + override-urile editate
rows: list[dict[str, Any]] = []
overrides: list[dict[str, Any]] = []
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"])
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 = {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 +
# validare nomenclator ca si commit-ul (2426), altfel un rand rezolvabil doar prin
# regula text ar fi marcat needs_mapping si exclus din commit. Incarcate o data.
# Paritate cu commit-ul: preview-ul web trebuie sa aplice ACELEASI reguli text +
# validare nomenclator, altfel un rand rezolvabil doar prin regula text ar fi marcat
# needs_mapping si exclus din commit. Incarcate o data.
valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct)
@@ -1980,7 +1975,7 @@ def _web_compute_preview(
"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))
already_sent_map = _already_sent_lookup(conn, account_id, unique_keys)
@@ -2091,7 +2086,7 @@ async def web_upload_import(
try:
sig = _signature(parsed.columns)
# Stagingul in DB (tranzactie explicita — Issue 6)
# Stagingul in DB (tranzactie explicita)
conn.execute("BEGIN IMMEDIATE")
try:
cur = conn.execute(
@@ -2330,15 +2325,15 @@ def web_preview_import(
# =========================================================================== #
# US-002 (3.6) — Editare celule in preview: mod editare pe rand. #
# Swap pe rand (#preview-row-N) + OOB contoare, NU pe #import-section (D-3.1). #
# Status rederivat DOAR prin _resolve_row_for_preview (H2 — fara clasificator). #
# Editare celule in preview: mod editare pe rand. #
# Swap pe rand (#preview-row-N) + OOB contoare, NU pe #import-section. #
# Status rederivat DOAR prin _resolve_row_for_preview (fara clasificator). #
# =========================================================================== #
def _preview_one_row(conn, import_id: int, account_id: int, row_index: int):
"""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
confirmarea (commit) vede starea editata. Intoarce (result, row) sau (mesaj, None)."""
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)
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)
conn = get_connection()
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)
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
contoare (D-3.1). Daca raman erori de continut pe camp, randul ramane in editare
cu valorile pastrate si mesajul pe campul vinovat (D-2.1/D-2.2)."""
Statusul e rederivat prin `_resolve_row_for_preview`. Swap pe rand + OOB contoare.
Daca raman erori de continut pe camp, randul ramane in editare cu valorile pastrate
si mesajul pe campul vinovat."""
account_id = require_login(request)
form = await request.form()
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
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.
C12: require_login — pe scrieri NICIODATA fallback cont 1 in prod.
account_id din sesiune propagat consecvent la build_key si toate lookup-urile.
require_login — pe scrieri NICIODATA fallback cont 1 in prod.
"""
account_id = require_login(request)
acct = account_or_default(account_id)
@@ -2644,11 +2639,11 @@ async def web_confirma_import(
# Mapare operatii
mapping_meta = load_mapping_meta(conn, acct)
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
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] = []
toctou: list[int] = []
rows_for_hash: list[str] = []
@@ -2696,7 +2691,7 @@ async def web_confirma_import(
"odometru_final": canon["odometru_final"],
})
# Override editat in preview (3.6) — aplicat ULTIMUL, ca in resolver.
# Override editat in preview — aplicat ULTIMUL, ca in resolver.
override = item.get("override") or {}
if override:
mapped.update(override)
@@ -2729,7 +2724,7 @@ async def web_confirma_import(
if cur.rowcount == 0:
toctou.append(row_index)
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)
enqueued.append({"submission_id": cur.lastrowid, "row_index": row_index})
@@ -2740,7 +2735,7 @@ async def web_confirma_import(
n_enqueued = len(enqueued)
# Log atestare (Voce#9)
# Log atestare
rows_hash = hashlib.sha256(
json.dumps(sorted(rows_for_hash), ensure_ascii=False).encode("utf-8")
).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:
# 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 ""
return templates.TemplateResponse("_upload.html", _ctx(
request,
@@ -2773,8 +2768,8 @@ async def web_confirma_import(
# =========================================================================== #
# US-007 — Sectiune "Contul meu": rotire cheie API + creds RAR din UI #
# Rute web proprii scoped pe sesiune (C13: nu reutilizeaza /v1/conturi/rar-creds
# Sectiune "Contul meu": rotire cheie API + creds RAR din UI. #
# Rute web proprii scoped pe sesiune (nu reutilizeaza /v1/conturi/rar-creds #
# care cere cheie API; sesiunea web e suficienta ca identitate). #
# =========================================================================== #
@@ -2846,10 +2841,9 @@ def integrare_test_cheie(
) -> HTMLResponse:
"""Verifica cheia API lipita de utilizator — scoped pe contul sesiunii.
US-004 (PRD Etapa 5): permite utilizatorului sa confirme ca o cheie copiata
din generatorul de exemple corespunde contului sau, fara efecte secundare
(fara creare/rotire). Cheie goala, invalida sau a altui cont -> mesaj de
eroare neutru (fara eco al cheii in raspuns).
Permite utilizatorului sa confirme ca o cheie copiata din generatorul de exemple
corespunde contului sau, fara efecte secundare (fara creare/rotire). Cheie goala,
invalida sau a altui cont -> mesaj de eroare neutru (fara eco al cheii in raspuns).
"""
account_id = require_login(request)
verify_csrf(request, csrf_token)
@@ -2901,7 +2895,7 @@ def cont_rar_creds(
rar_parola: str = Form(""),
csrf_token: str | None = Form(None),
) -> 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.
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:
- require_login() RIDICA LoginRequired
- app.main inregistreaza @app.exception_handler(LoginRequired) care intoarce
@@ -31,7 +31,7 @@ def current_account(request: Request) -> int | None:
def current_user_id(request: Request) -> int | None:
"""user_id din sesiune sau None (C19: leaga import_attestations.confirmed_by)."""
"""user_id din sesiune sau None (leaga import_attestations.confirmed_by)."""
val = request.session.get("user_id")
return int(val) if val is not None else None
@@ -88,7 +88,7 @@ def require_admin(request: Request) -> int:
def set_session(request: Request, account_id: int, user_id: int) -> None:
"""Seteaza sesiunea dupa login. Curata mai intai (C3 anti-fixare sesiune)."""
"""Seteaza sesiunea dupa login. Curata mai intai (anti-fixare sesiune)."""
request.session.clear()
request.session["account_id"] = account_id
request.session["user_id"] = user_id

View File

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

View File

@@ -1,8 +1,6 @@
{#
_coada.html — repurposat in 3.6 (US-003).
Nu mai e un tab/panou separat: e sectiunea "Trimiterile tale" inclusa pe Acasa,
sub zona de upload. Pastreaza filtrele (US-009) si tabelul (_submissions.html); detaliul
se deschide acum in modalul global (#modal-detaliu). Poll aliniat la 15s (anti dublu-poll, M5).
_coada.html — sectiunea "Trimiterile tale" inclusa pe Acasa, sub zona de upload.
Filtre + tabel (_submissions.html); detaliul se deschide in modalul global (#modal-detaliu).
#}
<section id="trimiteri-section" aria-labelledby="trimiteri-heading"
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);">
@@ -68,8 +66,4 @@
</div>
</div>
{# PRD 5.9 US-003: detaliul s-a mutat intr-un MODAL global (#modal-detaliu in base.html),
in afara #submissions-wrap -> poll-ul de 15s nu-l mai atinge. Randul declanseaza
deschiderea (hx-target=#detaliu-modal-body). Vechiul panou inert #trimitere-detaliu
a fost eliminat (rol preluat de modal). #}
</section>

View File

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

View File

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

View File

@@ -1,18 +1,14 @@
{# Macro-uri partajate intre template-urile de import si mapari. #}
{# US-003 (5.5): comutator COMPACT "In coada" — Manual <-> Auto pe un singur rand, fara
proza inline (explicatia traieste o singura data in panoul Ajutor al cardului Mapari).
Inlocuieste varianta verbose din 3.6 care injecta 3 randuri de text pe FIECARE linie de
tabel (randuri inalte -> Salveaza/Sterge ieseau din viewport).
INVARIANT BACKEND (nealterat din 3.6): control = checkbox cu `name="auto_send" value="true"`
si SEMANTICA DE PREZENTA (bifat -> trimite "true" -> True; nebifat -> camp absent -> False).
{# Comutator COMPACT "In coada" — Manual <-> Auto pe un singur rand.
INVARIANT BACKEND: control = checkbox cu `name="auto_send" value="true"` si
SEMANTICA DE PREZENTA (bifat -> trimite "true" -> True; nebifat -> camp absent -> False).
E singura forma compatibila cu AMBELE parsere: `Form(bool)` la /mapari SI `bool(form.get())`
la /_import/.../mapare-operatie. Radio Auto/Manual cu value="false" ar trimite campul prezent
pe "Manual" -> `bool("false")` = True la import (regresie tacuta). De aceea comutator vizual
Manual<->Auto peste checkbox, NU doua radio-uri. Zero atingere backend.
Manual<->Auto peste checkbox, NU doua radio-uri.
- form_id: leaga input-ul de un <form> extern (necesar in celulele de tabel).
- checked: starea STOCATA per mapare (H4) — bifat = Auto. #}
- checked: starea STOCATA per mapare — bifat = Auto. #}
{% macro autosend_toggle(form_id='', checked=True, label='') -%}
<label class="autosend-toggle"
title="Bifat = Auto: pune automat in coada la fisierele viitoare cu aceasta operatie. Nebifat = Manual: tine pentru verificare; nimic nu pleaca la RAR pana confirmi."

View File

@@ -4,7 +4,7 @@
/* Selectul de cod RAR e principalul vinovat de latimea tabelelor de mapari. Il limitam ca
tabelul sa incapa in card fara scroll orizontal -> coloana Actiuni (kebab) ramane vizibila. */
#mapari-section td select { width:100%; max-width:240px; min-width:150px; }
/* US-007 (R12): in card per rand (sub 767px) selectul/inputurile umplu cardul. */
/* In card per rand (sub 767px) selectul/inputurile umplu cardul. */
@media (max-width:767px) {
#mapari-section td select, #mapari-section td input[type=text] { max-width:none; min-width:0; }
}
@@ -18,11 +18,6 @@
<!-- Sectiunea 1: De rezolvat (operatii needs_mapping) -->
<!-- ============================================================ -->
<div class="card">
{# US-005 (5.5): antet standard + link Ajutor ca <details> nativ (fara JS). Toata proza
care inainte se repeta inline (scopul maparilor, Auto/Manual) traieste acum AICI,
o singura data, ascunsa implicit. #}
{# 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>
{% if pending %}
@@ -102,8 +97,6 @@
Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand.
</div>
{% else %}
{# US-005 (5.5): proza explicativa mutata in panoul Ajutor de la "De rezolvat" (o singura data). #}
<div data-dt="10">
<div class="dt-tools">
<input type="search" data-dt-search class="dt-search"
@@ -150,7 +143,7 @@
{{ ui.autosend_toggle(form_id="map-salv-" ~ loop.index, checked=m.auto_send) }}
</td>
<td style="text-align:right; white-space:nowrap;" data-eticheta="Actiuni">
{# 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,
JS adauga clasa "dirty" pe butonul de salvare (fundal --accent = modificari nesalvate). #}
<button type="submit" form="map-salv-{{ loop.index }}"
@@ -182,7 +175,6 @@
<!-- ============================================================ -->
<!-- Sectiunea 3: Reguli automate pe text (operation_text_rules) -->
<!-- US-010: mutata pe pozitia 3 (inainte de Formate de coloane) -->
<!-- ============================================================ -->
<div class="card">
<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>
</td>
</tr>
{# Preview pre-salvare (US-009): cate operatii nemapate potriveste pattern-ul. #}
{# Preview pre-salvare: cate operatii nemapate potriveste pattern-ul. #}
<tr>
<td colspan="4" style="padding-top:0;">
<div id="rt-preview" aria-live="polite"></div>
@@ -279,7 +271,6 @@
<!-- ============================================================ -->
<!-- Sectiunea 4: Formate de coloane salvate (column_mappings) -->
<!-- US-010: mutata pe pozitia 4 (dupa Reguli automate) -->
<!-- ============================================================ -->
<div class="card">
<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):
.tablewrap > table, antet th standard (mostenit din base.html), cod in .pill,
denumire ca text normal (singura coloana care se poate rupe pe randuri inguste),
empty-state in .empty. Zero stiluri inline noi — totul vine din base.html. #}
{# Aceeasi grila standard ca tabelul Trimiteri: cod in .pill, denumire ca text normal
(singura coloana care se poate rupe pe randuri inguste), empty-state in .empty. #}
{% if rows %}
<div class="tablewrap">
<table>

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{#
OOB: actualizeaza inputul id="f-page" din #filtre-trimiteri (US-004 L2).
Poll-ul de 15s (hx-include="#filtre-trimiteri") preia automat pagina curenta.
OOB: actualizeaza inputul id="f-page" din #filtre-trimiteri.
Reincarcarea (hx-include="#filtre-trimiteri") preia automat pagina curenta.
Elementul OOB e extras din continutul normal de HTMX inainte de swap in #submissions-wrap.
#}
<input type="hidden" id="f-page" name="page" value="{{ page | default(1) }}" hx-swap-oob="true">
@@ -13,7 +13,7 @@
<span id="trimiteri-versiune" data-v="{{ versiune_trimiteri | default('') }}" hidden></span>
{% if rows %}
{# US-011: form de stergere bulk. Selectia opereaza DOAR pe randuri blocate
{# Form de stergere bulk. Selectia opereaza DOAR pe randuri blocate
(gestionabil); sent/sending/queued nu au checkbox (read-only). #}
<form id="bulk-trimiteri"
hx-post="/trimiteri/sterge-bulk"
@@ -43,9 +43,8 @@
</tr></thead>
<tbody>
{% for r in rows %}
{# PRD 5.9 US-003: randul declanseaza deschiderea MODALULUI global (#detaliu-modal-body),
nu un rand-sibling. Clickabil/focusabil (role=button); Enter/Space deschid modalul
(JS in base.html). Vechiul rand-sibling de detaliu a fost eliminat. #}
{# Randul declanseaza deschiderea MODALULUI global (#detaliu-modal-body).
Clickabil/focusabil (role=button); Enter/Space deschid modalul (JS in base.html). #}
<tr id="trimitere-row-{{ r.id }}"
class="trimitere-row"
data-detaliu-id="{{ r.id }}"
@@ -65,8 +64,8 @@
<td class="col-id muted" data-eticheta="#">{{ r.id }}</td>
<td class="col-stare" data-eticheta="Stare">
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span>
{# PRD 5.9 US-002 (R1): eticheta umana scurta sub pill — text mic, `s-error`
pe error/needs_* (singurele stari pe care `eticheta_problema` e ne-goala).
{# Eticheta umana scurta sub pill — text mic, `s-error` pe error/needs_*
(singurele stari pe care `eticheta_problema` e ne-goala).
Stare transmisa prin TEXT, nu doar culoare. Codul brut ramane in modal. #}
{% if r.eticheta_problema %}
<div class="eticheta-problema s-error">{{ r.eticheta_problema }}</div>
@@ -75,14 +74,14 @@
<td class="col-vehicul" data-eticheta="Vehicul">
{{ r.prez.vehicul_nr }}
{% 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>
{% endif %}
</td>
<td class="col-operatie" data-eticheta="Operatie">
<div>{{ r.prez.operatie }}</div>
{# PRD 5.9 US-002: doar codul RAR (ex. OE-2), FARA prefixul "cod RAR:" — chip
muted discret; cand nemapat afiseaza "nemapat" muted (comportament 5.8). #}
{# Doar codul RAR (ex. OE-2), FARA prefixul "cod RAR:" — chip muted discret;
cand nemapat afiseaza "nemapat" muted. #}
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %}
<div class="cod-rar-sub"><span class="cod-rar-cod">{{ r.prez.cod_rar }}</span></div>
{% else %}
@@ -100,7 +99,7 @@
</form>
{#
Paginare numerotata (US-004 PRD 5.10).
Paginare numerotata.
Afisata doar cand exista mai mult de o pagina.
Fiecare link pastreaza filtrele curente (status, vehicul, data_de, data_pana).
Pagina curenta: aria-current="page" (semantic).

View File

@@ -1,14 +1,13 @@
{% from "_eroare.html" import card_erori %}
{% import '_macros.html' as ui %}
{# PRD 5.9 US-004: detaliu editabil in-place, butoane consolidate, ordine verticala R10.
Fragmentul se swap-uieste in corpul modalului global (#detaliu-modal-body). Heading-ul
poarta id-ul folosit de aria-labelledby al dialogului.
R9: operatie + cod RAR rezolvat apar IMPREUNA, read-only, folosind `prez.cod_rar`
{# Detaliu editabil in-place. Fragmentul se swap-uieste in corpul modalului global
(#detaliu-modal-body). Heading-ul poarta id-ul folosit de aria-labelledby al dialogului.
Operatie + cod RAR rezolvat apar IMPREUNA, read-only, folosind `prez.cod_rar`
(fallback „nemapat"), fara eticheta separata „Cod RAR". #}
{% set cod_afis = prez.cod_rar if (prez.cod_rar and prez.cod_rar != '—') else 'nemapat' %}
<div class="card" id="detaliu-card-{{ id }}" style="border:none; padding:0; margin:0;">
{# === R10 (1): header — #id + pill + motiv uman === #}
{# === Header — #id + pill + motiv uman === #}
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 8px;">
<h2 id="detaliu-modal-titlu" style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
<span class="pill {{ stare_css }}">{{ stare_text }}</span>
@@ -19,7 +18,7 @@
<p class="muted" style="margin:0 0 12px; font-size:13px;">{{ stare_subtext }}</p>
{% 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`
(text simplu .s-error) + rezumat top-of-form pentru erori fara camp (mai jos). === #}
{% if not editabil and erori_3n %}
@@ -28,7 +27,7 @@
</div>
{% endif %}
{# === R10 (3) + R9: mapare inline (PRD 5.7) — alege cod RAR pentru operatiile nemapate.
{# === Mapare inline — alege cod RAR pentru operatiile nemapate.
Cand nemapate_inline, linia „Operatie: X · nemapat" apare in formularul de mai jos
(cod_afis = nemapat), iar aici e picker-ul; dupa mapare, re-render arata codul rezolvat. === #}
{% if nemapate_inline %}
@@ -78,7 +77,7 @@
</div>
{% endif %}
{# === R10 (4): formular editabil (needs_data/needs_mapping) SAU context read-only.
{# === Formular editabil (needs_data/needs_mapping) SAU context read-only.
Zero dublare: campurile vehiculului apar O SINGURA DATA — editabile cand randul e
corectabil, altfel read-only. Operatie + cod RAR read-only deasupra campurilor. === #}
{% if editabil %}
@@ -90,7 +89,7 @@
{% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div>
{% 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.
Erori cu camp raman afisate per-camp de macro-ul `camp` de mai jos. #}
{% for e in erori_3n if not e.field %}
@@ -114,7 +113,7 @@
hx-disabled-elt="find button">
<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). #}
{% if nomenclator_rar %}
<div style="margin:0 0 12px;">
@@ -133,15 +132,15 @@
</select>
</div>
{% 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 class="muted" style="font-size:12px;">Operatie</div>
<div>{{ prez.operatie }} &middot; {{ cod_afis }}</div>
</div>
{% endif %}
{# US-007: operatie service (cod intern + denumire venita prin API/import), distinct de
operatia RAR mapata. Conventie US-002: op_service_cod="" cand lipseste → randul absent. #}
{# Operatie service (cod intern + denumire venita prin API/import), distinct de
operatia RAR mapata. op_service_cod="" cand lipseste → randul absent. #}
{% if prez.op_service_cod %}
<div style="margin:0 0 12px;">
<div class="muted" style="font-size:12px;">Operatie service</div>
@@ -160,7 +159,7 @@
{{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial) }}
</div>
{# === R10 (5): actiune primara conditionata de stare (R2). needs_data/needs_mapping
{# === Actiune primara conditionata de stare. needs_data/needs_mapping
-> „Salveaza si retrimite" pe /corecteaza. UN SINGUR buton primar per stare. === #}
<div style="margin-top:14px;">
<button type="submit">Salveaza si retrimite</button>
@@ -177,8 +176,8 @@
<div style="word-break:break-all;">{{ prez.vin }}</div>
</div>
<div><div class="muted" style="font-size:12px;">Operatie</div><div>{{ prez.operatie }} &middot; {{ cod_afis }}</div></div>
{# US-007: operatie service (cod intern + denumire), distinct de operatia RAR.
Conventie US-002: op_service_cod="" cand lipseste → randul absent (fara "—"). #}
{# Operatie service (cod intern + denumire), distinct de operatia RAR.
op_service_cod="" cand lipseste → randul absent (fara "—"). #}
{% if prez.op_service_cod %}
<div><div class="muted" style="font-size:12px;">Operatie service</div>
<div>{{ prez.op_service_cod }}{% if prez.op_service_denumire %} — {{ prez.op_service_denumire }}{% endif %}</div></div>
@@ -188,17 +187,17 @@
</div>
{% endif %}
{# === R10 (5): actiuni de jos — primar Re-pune (doar error) + Sterge pe RAND SEPARAT (R2/R11) === #}
{# === Actiuni de jos — primar Re-pune (doar error) + Sterge pe RAND SEPARAT === #}
{% if status == 'error' or gestionabil %}
<div class="detaliu-actiuni-jos" style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);">
{# R2: error -> buton primar „Re-pune in coada" pe /repune (error nu e editabil pentru #}
{# campuri vehicul, dar US-006b permite schimbarea cod_prestatie prin acelasi formular). #}
{# Error -> buton primar „Re-pune in coada" pe /repune (error nu e editabil pentru #}
{# campuri vehicul, dar se poate schimba cod_prestatie prin acelasi formular). #}
{% if status == 'error' %}
<form hx-post="/trimitere/{{ id }}/repune"
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
hx-disabled-elt="find button" style="margin:0 0 10px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{# 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 %}
<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)
@@ -219,7 +218,7 @@
</form>
{% endif %}
{# R11: UN SINGUR Sterge, outline distructiv (var(--err)), pe rand separat, full-width pe mobil. #}
{# UN SINGUR Sterge, outline distructiv (var(--err)), pe rand separat, full-width pe mobil. #}
{% if gestionabil %}
<form hx-post="/trimitere/{{ id }}/sterge"
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
@@ -235,7 +234,7 @@
</div>
{% endif %}
{# === R10 (6): Detalii tehnice — colapsat implicit === #}
{# === Detalii tehnice — colapsat implicit === #}
<details style="margin-top:14px;">
<summary class="muted" style="font-size:12px; cursor:pointer;">Detalii tehnice</summary>
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:10px 24px; margin-top:10px;">
@@ -257,7 +256,6 @@
{% endif %}
</details>
</div>
{# PRD 5.9 US-004 (R4): scriptul inline vechi (marcheazaDetaliuDeschis / scrollIntoView pe
randul-sibling) a fost eliminat de US-003. Focus-ul post-swap (incl. re-render corectie/
mapare) e gestionat de htmx:afterSettle pe #detaliu-modal-body din base.html. R5: inchiderea
modalului pe succes (queued/sterge) vine din HX-Trigger `inchideModal` emis de rute. #}
{# Focus-ul post-swap (incl. re-render corectie/mapare) e gestionat de htmx:afterSettle pe
#detaliu-modal-body din base.html. Inchiderea modalului pe succes (queued/sterge) vine
din HX-Trigger `inchideModal` emis de rute. #}

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
<title>{% block title %}Gateway RAR AUTOPASS{% endblock %}</title>
<script src="/static/htmx.min.js"></script>
<script>
// US-002 (3.6): raspunsurile de editare-rand contin un <tr> (swap pe rand) PLUS
// Raspunsurile de editare-rand contin un <tr> (swap pe rand) PLUS
// elemente OOB non-rand (#preview-rezumat, #preview-ok-count). Fara fragmente-template,
// htmx parseaza raspunsul care incepe cu <tr> in context de tabel (<table><tbody>) si
// "foster-parent"-eaza div/span-urile OOB afara din fragment -> swapError + contoare pierdute.
@@ -14,8 +14,8 @@
htmx.config.useTemplateFragments = true;
</script>
<script>
// Anti-FOUC (US-001 PRD 5.3, extins US-014 PRD 5.10): citeste preferinta tema din
// localStorage inainte de primul paint; seteaza data-theme pe <html> sincron, fara blink.
// Anti-FOUC: citeste preferinta tema din localStorage inainte de primul
// paint; seteaza data-theme pe <html> sincron, fara blink.
// Cunoaste toate cele 4 teme: light/dark/petrol/auto. Valoare legacy/necunoscuta -> auto.
// 'auto' se rezolva la 'light' sau 'dark' dupa prefers-color-scheme (fara blink).
(function() {
@@ -33,12 +33,9 @@
})();
</script>
<style>
/* US-013 (PRD 5.10): 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).
FOUT pe tabular-nums: IBM Plex Sans are metrici apropiate de system-ui; reflow-ul vizibil
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. */
/* IBM Plex Sans + Mono self-hosted (latin-ext pentru diacritice romanesti).
font-display:swap permite text vizibil inainte de incarcare (FOUT system-ui->IBM Plex);
reflow-ul vizibil pe VIN/coduri e acceptat explicit. */
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
@@ -103,38 +100,32 @@
src: url("/static/fonts/IBMPlexMono-Regular-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
}
/* Paleta dark (default) — accent azur ROMFAST conform DESIGN.md */
/* Paleta dark (default) — accent azur ROMFAST */
:root { --bg:#0f1218; --card:#181c24; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36;
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6; }
/* Paleta light — accent azur inchis pentru contrast AA pe alb (#1F66C9: 5.51:1 pe alb) */
[data-theme="light"] { --bg:#f5f7fa; --card:#ffffff; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea;
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#1F66C9; }
/* Paleta Petrol (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. */
[data-theme="petrol"] { --bg:#0e1416; --card:#161e20; --ink:#e6e9ef; --muted:#8b93a7; --line:#232c2e;
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#0E7C7B; }
* { box-sizing:border-box; }
/* PRD 5.9 US-006 — CONVENTIE BREAKPOINT: un singur prag mobil la 768px.
/* CONVENTIE BREAKPOINT: un singur prag mobil la 768px.
CSS custom properties NU functioneaza in `@media`, deci pragul nu poate fi o
variabila; folosim consecvent `@media (max-width:767px)` peste tot (mobil) si
`@media (max-width:1024px)` doar pentru densitatea tabelului. >=1024px = layout
desktop neschimbat (fara regresie). Orice regula mobila noua reutilizeaza 767px. */
body { margin:0; font:15px/1.5 "IBM Plex Sans",system-ui,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
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);
display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:8px; min-height:92px; }
.header-left { display:flex; align-items:center; }
.header-center { display:flex; flex-direction:column; align-items:center; text-align:center; }
.header-right { display:flex; align-items:center; justify-content:flex-end; gap:8px; }
/* US-012c: logo PNG ROMFAST in header-left (brand top-left ca pe romfast.ro).
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. */
/* Logo ROMFAST aliniat stanga; transparent, ok pe dark/light/petrol fara filtre de culoare. */
.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 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; }
@@ -210,7 +201,7 @@
button { background:var(--accent); border-color:var(--accent); color:#fff; cursor:pointer; }
button:hover { filter:brightness(1.08); }
.chk { font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px; }
/* Tab-bar (US-003) */
/* Tab-bar */
.tab-bar { display:flex; gap:2px; overflow-x:auto; -webkit-overflow-scrolling:touch;
border-bottom:1px solid var(--line); margin-bottom:16px; padding-bottom:0;
scrollbar-width:none; }
@@ -224,7 +215,7 @@
border-color:var(--line); border-bottom-color:var(--card); }
.tab-panel { min-height:120px; }
.status-bar { margin-bottom:12px; }
/* Eroare 3 niveluri (US-006, PRD 5.4) */
/* Eroare 3 niveluri */
.eroare-3n { margin-top:10px; }
.eroare-3n-item { padding:8px 10px; border-left:3px solid var(--err);
background:color-mix(in srgb, var(--err) 8%, var(--card));
@@ -237,13 +228,13 @@
.eroare-3n-label { font-weight:500; }
/* Inline fix per camp in preview */
.camp-fix { color:var(--accent); font-size:11px; margin-top:2px; display:block; }
/* Meniu hamburger cont (US-006 PRD 5.5) — dropdown ancorat dreapta-sus */
/* Meniu hamburger cont — dropdown ancorat dreapta-sus */
.cont-menu-wrap { position:relative; }
.icon-btn { background:transparent; border:1px solid var(--line); color:var(--ink); cursor:pointer;
border-radius:6px; min-height:36px; min-width:36px; font-size:16px; padding:4px 8px;
line-height:1; display:inline-flex; align-items:center; justify-content:center; }
.icon-btn:hover { background:var(--line); }
/* 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:hover { filter:brightness(0.9); }
.icon-btn.danger { color:var(--err); border-color:var(--err); }
@@ -260,7 +251,7 @@
.cont-menu form { margin:0; }
/* Kebab partajat (actiuni per-rand in tabele). Meniul e position:fixed si pozitionat de JS:
altfel `.tablewrap { overflow-x:auto }` induce overflow-y:auto si TAIE dropdown-ul pe ultimul
rand (bug 5.5 — meniul nu se vedea). fixed scoate meniul din contextul de clipping al tabelului. */
rand. fixed scoate meniul din contextul de clipping al tabelului. */
.kebab { position:relative; display:inline-block; }
.kebab > summary { list-style:none; cursor:pointer; display:inline-flex; align-items:center;
justify-content:center; min-height:32px; min-width:32px; padding:4px 10px;
@@ -288,7 +279,7 @@
.dt-pager button { background:transparent; color:var(--ink); border:1px solid var(--line);
padding:5px 12px; min-height:32px; }
.dt-pager button:disabled { opacity:.45; cursor:default; }
/* === Tabel trimiteri (PRD 5.8 US-007): fara scroll orizontal. SCOPAT prin
/* === Tabel trimiteri: fara scroll orizontal. SCOPAT prin
.tabel-trimiteri ca sa NU strice celelalte tabele (.tablewrap e partajat de
Mapari/Formate). Permitem wrap controlat pe coloanele text + latimi rezonabile. === */
.tabel-trimiteri table { table-layout:fixed; }
@@ -302,16 +293,15 @@
.tabel-trimiteri .col-operatie > div { line-height:1.35; }
/* secundarul muted („cod RAR" / „nemapat") — >=12px, contrast pe var(--muted) >=4.5:1 */
.tabel-trimiteri .cod-rar-sub { font-size:12px; margin-top:2px; }
/* PRD 5.9 US-002: codul RAR pe linia 2 — chip discret, fara prefixul „cod RAR:". */
/* Codul RAR pe linia 2 — chip discret, fara prefixul „cod RAR:". */
.tabel-trimiteri .cod-rar-cod { display:inline-block; font-family:"IBM Plex Mono",ui-monospace,monospace;
font-size:12px; padding:1px 7px; border:1px solid var(--line);
border-radius:99px; color:var(--muted); }
/* PRD 5.9 US-002 (R1): eticheta umana scurta sub pill — text mic; clasa `s-error`
o coloreaza (apare doar pe error/needs_*). Stare prin text, nu doar culoare. */
/* Eticheta umana scurta sub pill — text mic; clasa `s-error` o coloreaza
(apare doar pe error/needs_*). Stare prin text, nu doar culoare. */
.tabel-trimiteri .eticheta-problema { font-size:12px; line-height:1.3; margin-top:3px; }
/* PRD 5.9 US-002 (R8): randul e clickabil (deschide modalul) -> tinta de atins >=44px
(touch) + afordanta hover/focus. Inlocuieste vechea regula `@media pointer:coarse
.chevron` (chevron eliminat); este SINGURA regula 44px pe rand. */
/* Randul e clickabil (deschide modalul) -> tinta de atins >=44px (touch) +
afordanta hover/focus. */
.tabel-trimiteri tr.trimitere-row { min-height:44px; }
.tabel-trimiteri tr.trimitere-row > td { padding-top:11px; padding-bottom:11px; }
.tabel-trimiteri tr.trimitere-row:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
@@ -321,10 +311,10 @@
@media (max-width:1024px) {
.tabel-trimiteri .col-actualizat { display:none; }
}
/* === Modal detaliu (PRD 5.9 US-003): fereastra modala globala, in afara zonei de
poll (#submissions-wrap). Backdrop + dialog centrat pe desktop; focus-trap +
/* === Modal detaliu: fereastra modala globala, in afara zonei de poll
(#submissions-wrap). Backdrop + dialog centrat pe desktop; focus-trap +
scroll-lock + inert pe <main> sunt in JS. Varianta full-screen mobil: vezi blocul
`@media (max-width:767px)` US-006 de mai jos. === */
`@media (max-width:767px)` de mai jos. === */
.modal-overlay { position:fixed; inset:0; z-index:1100; display:flex;
align-items:flex-start; justify-content:center; padding:40px 16px; overflow-y:auto; }
.modal-overlay[hidden] { display:none; }
@@ -341,9 +331,9 @@
body.modal-open { overflow:hidden; }
.modal-eroare { padding:16px 4px; }
.modal-eroare .actiuni { margin-top:12px; display:flex; gap:10px; flex-wrap:wrap; }
/* === PRD 5.9 US-006: fundatie responsive mobil (<768px) ===
/* === Fundatie responsive mobil (<768px) ===
Breakpoint unic 767px (vezi conventia de sus). Cuprinde: card per rand pe tabelul
de trimiteri (5.8, pastrat), modal full-screen, header/nav colapsat cu tinte touch
de trimiteri, modal full-screen, header/nav colapsat cu tinte touch
>=44px. Desktop (>=1024px) ramane neschimbat — regulile de baza nu se modifica. */
@media (max-width:767px) {
/* Tabel trimiteri: card per rand (eticheta:valoare stivuit) -> fara scroll orizontal */
@@ -366,7 +356,7 @@
padding:16px; padding-top:56px; overflow-y:auto; }
.modal-close { width:44px; height:44px; top:8px; right:8px; font-size:24px; }
/* US-004 (R11): actiunile de jos din detaliu (Re-pune / Sterge) full-width stivuit pe mobil. */
/* Actiunile de jos din detaliu (Re-pune / Sterge) full-width stivuit pe mobil. */
.detaliu-actiuni-jos button { width:100%; }
/* Header + nav colapsate: pe mobil trece de la grid la flex wrap.
@@ -383,9 +373,9 @@
.tab-link { min-height:44px; padding:10px 14px; }
.cont-menu a, .cont-menu button { min-height:44px; }
/* === PRD 5.9 US-007 (R12): paginile de continut pe mobil ===
/* === Paginile de continut pe mobil ===
Tabele ACTIONABILE (Mapari) -> card per rand. Clasa proprie `.tabel-card`,
scopata SEPARAT de `.tabel-trimiteri` (5.8) ca sa NU strice cardurile de
scopata SEPARAT de `.tabel-trimiteri` ca sa NU strice cardurile de
trimiteri. Tabele DENSE read-only (Jurnal, Nomenclator) + Admin raman in
`.tablewrap` (scroll orizontal CONTAINED, definit global mai sus). */
.tabel-card table { table-layout:auto; }
@@ -417,11 +407,11 @@
#card-cont button, #form-test-cheie button,
#jurnal-section #filtre-jurnal button { min-height:44px; width:100%; }
/* === PRD 5.9 US-008: Acasa (upload, status, filtre) + login/signup pe mobil ===
/* === Acasa (upload, status, filtre) + login/signup pe mobil ===
Zona de upload, bara de status si bara de filtre (`_coada.html`) stiveaza pe O
coloana sub 767px; inputuri/butoane full-width cu tinta touch >=44px. Scopat pe
id-urile sectiunilor de pe Acasa ca sa NU atinga tabelul de trimiteri (5.8),
modalul sau paginile de continut (US-007). */
id-urile sectiunilor de pe Acasa ca sa NU atinga tabelul de trimiteri,
modalul sau paginile de continut. */
/* Bara de upload: zona slim (returning user) trece pe coloana; butonul full-width. */
#import-section .drop-zone { flex-direction:column; align-items:stretch; text-align:left; }
#import-section #upload-btn { width:100%; min-height:44px; }
@@ -441,16 +431,14 @@
</style>
</head>
<body>
{# US-012c (PRD 5.10): 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. #}
{# Grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). #}
<header>
{# Celula stanga: logo ROMFAST (US-012c: brand top-left ca pe romfast.ro) #}
{# Celula stanga: logo ROMFAST #}
<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">
</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">
<h1>Gateway RAR AUTOPASS</h1>
<span class="env">{{ rar_env }}</span>
@@ -462,14 +450,14 @@
title="Comuta tema">&#9728;</button>
<span class="muted" style="font-size:13px;">v{{ version }}</span>
{% if is_authenticated|default(false) %}
{# Meniu cont (US-006 PRD 5.5): Cont/Integrare/Nomenclator + (admin) + logout.
{# Meniu cont: Cont/Integrare/Nomenclator + (admin) + logout.
Pe paginile neautentificate (login/signup) nu se randeaza deloc. #}
<div class="cont-menu-wrap">
<button id="cont-menu-toggle" class="icon-btn"
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
aria-label="Meniu cont" title="Meniu cont">&#9776;</button>
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden>
{# 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) %}
<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>
@@ -488,14 +476,14 @@
{% endif %}
</div>
</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"
style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;"></span>
<main>{% block content %}{% endblock %}</main>
{# Modal detaliu trimitere (PRD 5.9 US-003): container global, SIBLING al <main>
(nu descendent), ca `inert`+`aria-hidden` pe <main> sa nu-l prinda si pe el (R7).
Corpul #detaliu-modal-body e tinta de swap pentru fragment + rutele corectie/
mapare/lifecycle. Traieste in afara #submissions-wrap -> poll-ul de 15s nu-l atinge. #}
{# Modal detaliu trimitere: container global, SIBLING al <main> (nu descendent),
ca `inert`+`aria-hidden` pe <main> sa nu-l prinda si pe el. Corpul
#detaliu-modal-body e tinta de swap pentru fragment + rutele corectie/mapare/
lifecycle. Traieste in afara #submissions-wrap -> poll-ul nu-l atinge. #}
<div id="modal-detaliu" class="modal-overlay" role="dialog" aria-modal="true"
aria-labelledby="detaliu-modal-titlu" hidden>
<div class="modal-backdrop" data-modal-close></div>
@@ -505,7 +493,7 @@
</div>
</div>
<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).
// 'auto' se rezolva la paint prin anti-FOUC; aici setam data-theme rezolvat.
(function() {
@@ -528,7 +516,7 @@
var s = VALID[stored] ? stored : 'auto';
btn.innerHTML = ICONS[s];
btn.setAttribute('aria-label', 'Tema: ' + LABELS[s] + ', apasa pentru ' + NEXT[s]);
btn.title = LABELS[s]; // 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) {
document.documentElement.setAttribute('data-theme', _resolved(t));
@@ -547,7 +535,7 @@
})();
</script>
<script>
// Meniu cont (US-006 PRD 5.5): dropdown ancorat dreapta-sus. Deschide/inchide la click,
// Meniu cont: dropdown ancorat dreapta-sus. Deschide/inchide la click,
// inchide la Esc (focus readus pe buton) si la click in afara. Fara dependente.
(function() {
var toggle = document.getElementById('cont-menu-toggle');
@@ -624,7 +612,7 @@
})();
</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
// legat prin data-dirty-form devine evidentiat (clasa "dirty" → fundal --accent).
// Starea "dirty" e efemera per-render: un swap outerHTML o reseteaza automat.
@@ -700,11 +688,11 @@
})();
</script>
<script>
// Modal detaliu trimitere (PRD 5.9 US-003): inlocuieste detaliul inline (5.8). Detaliul
// se incarca prin HTMX in #detaliu-modal-body (in afara #submissions-wrap, deci poll-ul
// de 15s nu-l atinge). Aici: deschidere la click pe rand, inchidere (x/Esc/backdrop),
// focus-trap, scroll-lock, inert+aria-hidden pe <main> (R7), stare de eroare la load
// esuat (R5), inchidere pe succes corectie/sterge (HX-Trigger inchideModal, R5).
// Modal detaliu trimitere: detaliul se incarca prin HTMX in #detaliu-modal-body
// (in afara #submissions-wrap, deci poll-ul nu-l atinge). Aici: deschidere la click
// pe rand, inchidere (x/Esc/backdrop), focus-trap, scroll-lock, inert+aria-hidden pe
// <main>, stare de eroare la load esuat, inchidere pe succes corectie/sterge
// (HX-Trigger inchideModal).
(function() {
var overlay = document.getElementById('modal-detaliu');
if (!overlay) return;
@@ -721,7 +709,7 @@
' select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'),
function(el) { return el.offsetParent !== null || el === document.activeElement; });
}
// R7: focus-trap — Tab/Shift+Tab cicleaza in interiorul dialogului.
// focus-trap — Tab/Shift+Tab cicleaza in interiorul dialogului.
function trapFocus(e) {
if (e.key !== 'Tab') return;
var f = focusable();
@@ -755,7 +743,6 @@
if (t && t.focus) t.focus(); // focus readus pe rand
}
// API public: butonul „Inchide" din fragment + inchiderea pe succes corectie/sterge.
// (Semnatura veche inchideDetaliu(id) pastrata, dar exista un singur modal o data.)
window.inchideDetaliu = function() { close(); };
// Inchidere: x si backdrop (elemente cu data-modal-close), Esc.
@@ -778,7 +765,7 @@
var f = focusable();
if (f.length) f[0].focus();
});
// R5: load-error al fragmentului (GET esuat) -> stare Reincearca/Inchide, nu placeholder blocat.
// Load-error al fragmentului (GET esuat) -> stare Reincearca/Inchide, nu placeholder blocat.
body.addEventListener('htmx:responseError', function(evt) {
if (!isOpen()) return;
var elt = evt.detail && evt.detail.elt;
@@ -796,7 +783,7 @@
{ target: body, swap: 'innerHTML' });
});
// R5: inchidere pe succes corectie/sterge — ruta emite HX-Trigger `inchideModal`.
// Inchidere pe succes corectie/sterge — ruta emite HX-Trigger `inchideModal`.
// Lista se reincarca separat prin `trimiteriChanged` (#submissions-wrap). Maparea
// inline NU emite inchideModal -> modalul ramane deschis sa arate codul rezolvat.
document.body.addEventListener('inchideModal', function() { close(); });

View File

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

View File

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