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:
@@ -8,14 +8,13 @@ Endpointuri:
|
|||||||
POST /v1/import/{id}/commit — gate HARD + enqueue randuri ok + log atestare
|
POST /v1/import/{id}/commit — gate HARD + enqueue randuri ok + log atestare
|
||||||
GET /v1/import/{id}/export-failed — CSV cu randuri esuate (needs_data/needs_mapping/needs_review)
|
GET /v1/import/{id}/export-failed — CSV cu randuri esuate (needs_data/needs_mapping/needs_review)
|
||||||
|
|
||||||
Reguli cheie (plan §3.1-3.4, §12):
|
Reguli cheie:
|
||||||
- Issue 6: scrieri bulk in tranzactie explicita BEGIN IMMEDIATE...COMMIT + executemany.
|
- Scrieri bulk in tranzactie explicita BEGIN IMMEDIATE...COMMIT + executemany.
|
||||||
- Eng#5: already_sent lookup BATCH (IN chunk ~900), nu N+1.
|
- already_sent lookup BATCH (IN chunk ~900), nu N+1.
|
||||||
- OV-3: duplicate_in_file EXCLUSIV la preview/commit. NU atinge reconcile.py/worker.
|
- duplicate_in_file EXCLUSIV la preview/commit. NU atinge reconcile.py/worker.
|
||||||
- Issue 1 (TOCTOU): commit per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
|
- TOCTOU: commit per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
|
||||||
- Issue 5a: import_rows.raw_json CRIPTAT Fernet.
|
- import_rows.raw_json CRIPTAT Fernet.
|
||||||
- Issue 5b: fuzzy coloane refoloseste mapping.normalize_for_match (DRY).
|
- Drift semnatura coloane -> NU aplica orb, cere re-confirmare.
|
||||||
- T4/D3: drift semnatura coloane -> NU aplica orb, cere re-confirmare.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -60,7 +59,7 @@ router = APIRouter(prefix="/v1/import", tags=["import"])
|
|||||||
# Marimea maxima a unui chunk pentru IN(...) SQLite (limite SQLite ~999)
|
# Marimea maxima a unui chunk pentru IN(...) SQLite (limite SQLite ~999)
|
||||||
_IN_CHUNK = 900
|
_IN_CHUNK = 900
|
||||||
|
|
||||||
# Campuri canonice si sinonimele lor pentru sugestie fuzzy coloane (Issue 5b/Eng#4)
|
# Campuri canonice si sinonimele lor pentru sugestie fuzzy coloane
|
||||||
_CANONICAL_SYNONYMS: dict[str, list[str]] = {
|
_CANONICAL_SYNONYMS: dict[str, list[str]] = {
|
||||||
"vin": ["VIN", "Serie sasiu", "Sasiu", "Serie", "Numar sasiu", "Nr sasiu", "Chassis"],
|
"vin": ["VIN", "Serie sasiu", "Sasiu", "Serie", "Numar sasiu", "Nr sasiu", "Chassis"],
|
||||||
"nr_inmatriculare": ["Nr inmatriculare", "Numar inmatriculare", "Numar auto", "Nr auto", "Numar", "Nr"],
|
"nr_inmatriculare": ["Nr inmatriculare", "Numar inmatriculare", "Numar auto", "Nr auto", "Numar", "Nr"],
|
||||||
@@ -93,7 +92,7 @@ def _fuzzy_suggest_column(
|
|||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Sugereaza campuri canonice pentru o coloana din fisier.
|
"""Sugereaza campuri canonice pentru o coloana din fisier.
|
||||||
|
|
||||||
Refoloseste normalize_for_match + rapidfuzz.fuzz.token_sort_ratio (Issue 5b/Eng#4).
|
Refoloseste normalize_for_match + rapidfuzz.fuzz.token_sort_ratio.
|
||||||
Intoarce [{camp_canonic, score}] sortat descrescator.
|
Intoarce [{camp_canonic, score}] sortat descrescator.
|
||||||
"""
|
"""
|
||||||
from rapidfuzz import fuzz, process
|
from rapidfuzz import fuzz, process
|
||||||
@@ -140,10 +139,10 @@ def _resolve_row_for_preview(
|
|||||||
errors: lista erori validare
|
errors: lista erori validare
|
||||||
flags: motive needs_review
|
flags: motive needs_review
|
||||||
|
|
||||||
`override` (3.6, Approach B): patch CANONIC editat in preview, aplicat ULTIMUL
|
`override`: patch CANONIC editat in preview, aplicat ULTIMUL peste valorile
|
||||||
peste valorile mapate (dupa `json_mapare` si canonicalizare). Permite corectarea
|
mapate (dupa `json_mapare` si canonicalizare). Permite corectarea unei valori
|
||||||
unei valori sau completarea unui camp a carui coloana LIPSESTE din fisier, fara
|
sau completarea unui camp a carui coloana LIPSESTE din fisier, fara sa atinga
|
||||||
sa atinga `raw_json`/idempotency.
|
`raw_json`/idempotency.
|
||||||
"""
|
"""
|
||||||
# Aplica maparea de coloane
|
# Aplica maparea de coloane
|
||||||
mapped: dict[str, Any] = {}
|
mapped: dict[str, Any] = {}
|
||||||
@@ -151,7 +150,7 @@ def _resolve_row_for_preview(
|
|||||||
if col_fisier in raw_row and camp_canonic:
|
if col_fisier in raw_row and camp_canonic:
|
||||||
mapped[camp_canonic] = raw_row[col_fisier]
|
mapped[camp_canonic] = raw_row[col_fisier]
|
||||||
|
|
||||||
# Detectie coloane cu formule (Issue 3) — nu blocheaza, dar adauga flag
|
# Detectie coloane cu formule — nu blocheaza, dar adauga flag
|
||||||
formula_flag: list[str] = []
|
formula_flag: list[str] = []
|
||||||
for col_fisier, camp_canonic in json_mapare.items():
|
for col_fisier, camp_canonic in json_mapare.items():
|
||||||
if col_fisier in formula_columns:
|
if col_fisier in formula_columns:
|
||||||
@@ -186,7 +185,7 @@ def _resolve_row_for_preview(
|
|||||||
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
||||||
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
|
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
|
||||||
|
|
||||||
# Canonicalizare (T9): normalizeaza VIN/nr/odometru
|
# Canonicalizare: normalizeaza VIN/nr/odometru
|
||||||
canon = canonicalize_row(mapped)
|
canon = canonicalize_row(mapped)
|
||||||
mapped.update({
|
mapped.update({
|
||||||
"vin": canon["vin"],
|
"vin": canon["vin"],
|
||||||
@@ -194,7 +193,7 @@ def _resolve_row_for_preview(
|
|||||||
"odometru_final": canon["odometru_final"],
|
"odometru_final": canon["odometru_final"],
|
||||||
})
|
})
|
||||||
|
|
||||||
# Override editat in preview (3.6) — aplicat ULTIMUL, peste valorile mapate +
|
# Override editat in preview — aplicat ULTIMUL, peste valorile mapate +
|
||||||
# canonicalizate. Valorile din override sunt deja canonice (vezi _merge_override).
|
# canonicalizate. Valorile din override sunt deja canonice (vezi _merge_override).
|
||||||
if override:
|
if override:
|
||||||
mapped.update(override)
|
mapped.update(override)
|
||||||
@@ -230,7 +229,7 @@ def _resolve_row_for_preview(
|
|||||||
"flags": all_flags,
|
"flags": all_flags,
|
||||||
}
|
}
|
||||||
|
|
||||||
# auto_send gate (T6/OV-1)
|
# auto_send gate
|
||||||
if has_no_auto_send(resolved, mapping_meta):
|
if has_no_auto_send(resolved, mapping_meta):
|
||||||
return {
|
return {
|
||||||
"resolved_status": "needs_mapping",
|
"resolved_status": "needs_mapping",
|
||||||
@@ -261,8 +260,8 @@ def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any]) ->
|
|||||||
return build_key(account_id, canon)
|
return build_key(account_id, canon)
|
||||||
|
|
||||||
|
|
||||||
# Campuri de continut editabile in preview (3.6). Operatia/codul RAR NU se editeaza
|
# Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza
|
||||||
# aici (raman in panoul de mapare) — vezi Non-Goals din PRD 3.6.
|
# aici (raman in panoul de mapare).
|
||||||
EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final")
|
EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final")
|
||||||
|
|
||||||
|
|
||||||
@@ -350,7 +349,7 @@ def apply_row_override(
|
|||||||
|
|
||||||
|
|
||||||
def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, dict]:
|
def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, dict]:
|
||||||
"""Cauta cheile de idempotenta in submissions (batch, nu N+1 — Eng#5).
|
"""Cauta cheile de idempotenta in submissions (batch, nu N+1).
|
||||||
|
|
||||||
Intoarce {idempotency_key: {id, id_prezentare, created_at}} pentru cheile gasite.
|
Intoarce {idempotency_key: {id, id_prezentare, created_at}} pentru cheile gasite.
|
||||||
"""
|
"""
|
||||||
@@ -371,7 +370,7 @@ def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, di
|
|||||||
"id_prezentare": r["id_prezentare"],
|
"id_prezentare": r["id_prezentare"],
|
||||||
"created_at": r["created_at"],
|
"created_at": r["created_at"],
|
||||||
}
|
}
|
||||||
# Dual-lookup pentru chei legacy (OV-2: chei vechi cu account_id=None)
|
# Dual-lookup pentru chei legacy (chei vechi cu account_id=None)
|
||||||
legacy_keys_needed = [k for k in chunk if k not in found]
|
legacy_keys_needed = [k for k in chunk if k not in found]
|
||||||
if legacy_keys_needed:
|
if legacy_keys_needed:
|
||||||
lph = ",".join("?" * len(legacy_keys_needed))
|
lph = ",".join("?" * len(legacy_keys_needed))
|
||||||
@@ -403,8 +402,7 @@ async def upload_import(
|
|||||||
"""Upload fisier xlsx/csv -> staging in import_batches/import_rows.
|
"""Upload fisier xlsx/csv -> staging in import_batches/import_rows.
|
||||||
|
|
||||||
Nu trimite nimic la RAR. Intoarce {import_id, columns, sample_rows, sheets?}.
|
Nu trimite nimic la RAR. Intoarce {import_id, columns, sample_rows, sheets?}.
|
||||||
PII (raw_json) criptat Fernet la rest (Issue 5a).
|
PII (raw_json) criptat Fernet la rest. Scrieri bulk in tranzactie explicita.
|
||||||
Scrieri bulk in tranzactie explicita (Issue 6).
|
|
||||||
"""
|
"""
|
||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
data = await file.read()
|
data = await file.read()
|
||||||
@@ -468,7 +466,7 @@ async def upload_import(
|
|||||||
try:
|
try:
|
||||||
sig = _signature(parsed.columns)
|
sig = _signature(parsed.columns)
|
||||||
|
|
||||||
# Issue 6: tranzactie explicita BEGIN IMMEDIATE + executemany
|
# Tranzactie explicita BEGIN IMMEDIATE + executemany
|
||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
try:
|
try:
|
||||||
# Insert import_batches
|
# Insert import_batches
|
||||||
@@ -482,7 +480,7 @@ async def upload_import(
|
|||||||
# Insert import_rows bulk (executemany) cu PII criptat
|
# Insert import_rows bulk (executemany) cu PII criptat
|
||||||
row_params = []
|
row_params = []
|
||||||
for i, row_dict in enumerate(parsed.rows):
|
for i, row_dict in enumerate(parsed.rows):
|
||||||
raw_json_enc = encrypt_creds(row_dict) # Criptat Fernet (Issue 5a)
|
raw_json_enc = encrypt_creds(row_dict) # Criptat Fernet
|
||||||
row_params.append((batch_id, i, raw_json_enc, "pending", None))
|
row_params.append((batch_id, i, raw_json_enc, "pending", None))
|
||||||
|
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
@@ -506,11 +504,8 @@ async def upload_import(
|
|||||||
# Sample rows (primele 3, fara PII)
|
# Sample rows (primele 3, fara PII)
|
||||||
sample = parsed.rows[:3]
|
sample = parsed.rows[:3]
|
||||||
|
|
||||||
# Persistam metadata parsedata (coercion_flags, date_col_format, formula_columns)
|
# Metadata parsata (coercion_flags etc.) se intoarce in raspuns; preview-ul
|
||||||
# in import_batches pentru refolosire la preview (stocam ca JSON in 'status' nu e OK,
|
# o recalculeaza din raw_json deja stocat.
|
||||||
# ci ca metadate suplimentare — le stocam intr-un rand separat sau returnam direct)
|
|
||||||
# Solutie: le returnam in raspuns; preview-ul le va recalcula din raw_json deja stocat
|
|
||||||
# SAU le stocam ca un camp extra. Cel mai simplu: stocam coloanele in batch.
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE import_batches SET ok=?, needs_review=? WHERE id=?",
|
"UPDATE import_batches SET ok=?, needs_review=? WHERE id=?",
|
||||||
(0, len(parsed.coercion_flags), batch_id),
|
(0, len(parsed.coercion_flags), batch_id),
|
||||||
@@ -532,7 +527,7 @@ async def upload_import(
|
|||||||
result["column_mapping"] = json.loads(existing_mapping["json_mapare"])
|
result["column_mapping"] = json.loads(existing_mapping["json_mapare"])
|
||||||
result["format_data"] = existing_mapping["format_data"]
|
result["format_data"] = existing_mapping["format_data"]
|
||||||
else:
|
else:
|
||||||
# Sugestii fuzzy per coloana (Issue 5b: refoloseste normalize_for_match)
|
# Sugestii fuzzy per coloana
|
||||||
suggestions: dict[str, list[dict]] = {}
|
suggestions: dict[str, list[dict]] = {}
|
||||||
for col in parsed.columns:
|
for col in parsed.columns:
|
||||||
sugg = _fuzzy_suggest_column(col, limit=3)
|
sugg = _fuzzy_suggest_column(col, limit=3)
|
||||||
@@ -676,7 +671,7 @@ def save_column_mapping(
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# GET /v1/import/{id}/preview — 6 stari per rand (T2 + T11) #
|
# GET /v1/import/{id}/preview — 6 stari per rand #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
@router.get("/{import_id}/preview")
|
@router.get("/{import_id}/preview")
|
||||||
@@ -686,8 +681,8 @@ def preview_import(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Preview 6 stari per rand: ok/needs_mapping/needs_data/needs_review/already_sent/duplicate_in_file.
|
"""Preview 6 stari per rand: ok/needs_mapping/needs_data/needs_review/already_sent/duplicate_in_file.
|
||||||
|
|
||||||
Nu enqueue-aza nimic. Already_sent = lookup batch (Eng#5). Duplicate_in_file = intra-batch
|
Nu enqueue-aza nimic. Already_sent = lookup batch. Duplicate_in_file = intra-batch
|
||||||
collision (OV-3: EXCLUSIV aici, NU in reconcile.py/worker).
|
collision (EXCLUSIV aici, NU in reconcile.py/worker).
|
||||||
"""
|
"""
|
||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -708,7 +703,7 @@ def preview_import(
|
|||||||
if not raw_rows_db:
|
if not raw_rows_db:
|
||||||
return {"rows": [], "summary": {}}
|
return {"rows": [], "summary": {}}
|
||||||
|
|
||||||
# Decripteaza si reconstruieste randurile + override-urile editate (3.6)
|
# Decripteaza si reconstruieste randurile + override-urile editate
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
overrides: list[dict] = []
|
overrides: list[dict] = []
|
||||||
for r in raw_rows_db:
|
for r in raw_rows_db:
|
||||||
@@ -747,22 +742,18 @@ def preview_import(
|
|||||||
json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"])
|
json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"])
|
||||||
format_data = mapping_row["format_data"]
|
format_data = mapping_row["format_data"]
|
||||||
|
|
||||||
# Incarca maparea de operatii o singura data (Eng#5: load_mapping o singura data)
|
# Incarca maparea de operatii o singura data
|
||||||
mapping_meta = load_mapping_meta(conn, acct)
|
mapping_meta = load_mapping_meta(conn, acct)
|
||||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||||
# T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
# Validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
||||||
valid_codes = load_nomenclator_codes(conn) or None
|
valid_codes = load_nomenclator_codes(conn) or None
|
||||||
text_rules = load_text_rules(conn, acct)
|
text_rules = load_text_rules(conn, acct)
|
||||||
|
|
||||||
# Reconstruieste parsed info (coercion_flags si date_col_format) din datele stocate
|
# Recalculam coercion_flags din valorile stocate (nu sunt persistate separat):
|
||||||
# Nota: import_rows stocheaza raw_json dupa coercion (din parse_file)
|
# detectie simpla de VIN numeric.
|
||||||
# Recalculam flags din valorile stocate (coercion_flags nu e stocat separat)
|
|
||||||
# Vom folosi o detectie simpla: VIN-uri care par numerice si odometru float
|
|
||||||
coercion_flags_map: dict[int, list[str]] = {}
|
coercion_flags_map: dict[int, list[str]] = {}
|
||||||
# Detectam din valorile stocate
|
|
||||||
for i, row_dict in enumerate(rows):
|
for i, row_dict in enumerate(rows):
|
||||||
flags = []
|
flags = []
|
||||||
# Detectam VIN numeric: daca valoarea a fost stocata si arata ca numar
|
|
||||||
for col_f, camp_c in json_mapare.items():
|
for col_f, camp_c in json_mapare.items():
|
||||||
if camp_c == "vin":
|
if camp_c == "vin":
|
||||||
vin_val = row_dict.get(col_f)
|
vin_val = row_dict.get(col_f)
|
||||||
@@ -830,11 +821,11 @@ def preview_import(
|
|||||||
"idempotency_key": key,
|
"idempotency_key": key,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Already_sent: batch lookup (Eng#5 — nu N+1)
|
# Already_sent: batch lookup (nu N+1)
|
||||||
unique_keys = list(set(keys_for_lookup))
|
unique_keys = list(set(keys_for_lookup))
|
||||||
already_sent_map = _already_sent_lookup(conn, account_id, unique_keys)
|
already_sent_map = _already_sent_lookup(conn, account_id, unique_keys)
|
||||||
|
|
||||||
# Duplicate_in_file (OV-3): detectie coliziuni intra-batch
|
# Duplicate_in_file: detectie coliziuni intra-batch.
|
||||||
# Grupam pe cheie de idempotenta: >1 rand cu aceeasi cheie = duplicate
|
# Grupam pe cheie de idempotenta: >1 rand cu aceeasi cheie = duplicate
|
||||||
key_to_indices: dict[str, list[int]] = {}
|
key_to_indices: dict[str, list[int]] = {}
|
||||||
for row in preview_rows:
|
for row in preview_rows:
|
||||||
@@ -857,7 +848,7 @@ def preview_import(
|
|||||||
row["already_sent_info"] = sent_info
|
row["already_sent_info"] = sent_info
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Duplicate_in_file (OV-3): >1 rand cu aceeasi cheie in ACELASI fisier
|
# Duplicate_in_file: >1 rand cu aceeasi cheie in ACELASI fisier
|
||||||
indices_with_same_key = key_to_indices.get(k, [])
|
indices_with_same_key = key_to_indices.get(k, [])
|
||||||
if len(indices_with_same_key) > 1 and row["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
if len(indices_with_same_key) > 1 and row["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
||||||
others = [idx for idx in indices_with_same_key if idx != row["row_index"]]
|
others = [idx for idx in indices_with_same_key if idx != row["row_index"]]
|
||||||
@@ -911,7 +902,7 @@ def preview_import(
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# POST /v1/import/{id}/commit — gate HARD + enqueue + log atestare (T5+T12) #
|
# POST /v1/import/{id}/commit — gate HARD + enqueue + log atestare #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
class CommitIn(BaseModel):
|
class CommitIn(BaseModel):
|
||||||
@@ -929,9 +920,9 @@ def commit_import(
|
|||||||
req: CommitIn,
|
req: CommitIn,
|
||||||
account_id: int = Depends(resolve_account_id),
|
account_id: int = Depends(resolve_account_id),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Gate HARD confirmare + enqueue randuri ok + log atestare (T5+T12).
|
"""Gate HARD confirmare + enqueue randuri ok + log atestare.
|
||||||
|
|
||||||
TOCTOU (Issue 1): INSERT per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
|
TOCTOU: INSERT per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
|
||||||
Randuri colidante = reclasificate already_sent in rezultatul commit-ului.
|
Randuri colidante = reclasificate already_sent in rezultatul commit-ului.
|
||||||
rows_hash + n_confirmed acopera DOAR randurile efectiv puse in coada.
|
rows_hash + n_confirmed acopera DOAR randurile efectiv puse in coada.
|
||||||
"""
|
"""
|
||||||
@@ -981,7 +972,7 @@ def commit_import(
|
|||||||
elif r["resolved_status"] == "needs_review":
|
elif r["resolved_status"] == "needs_review":
|
||||||
review_indices.add(r["row_index"])
|
review_indices.add(r["row_index"])
|
||||||
|
|
||||||
# needs_review bifate explicit (Voce#1 — atestare pe valori)
|
# needs_review bifate explicit (atestare pe valori)
|
||||||
confirmed_review = [idx for idx in req.reviewed_rows if idx in review_indices]
|
confirmed_review = [idx for idx in req.reviewed_rows if idx in review_indices]
|
||||||
for idx in confirmed_review:
|
for idx in confirmed_review:
|
||||||
# Gaseste randul needs_review si il adauga la ok_rows
|
# Gaseste randul needs_review si il adauga la ok_rows
|
||||||
@@ -1040,7 +1031,7 @@ def commit_import(
|
|||||||
# Incarca maparea de operatii
|
# Incarca maparea de operatii
|
||||||
mapping_meta = load_mapping_meta(conn, acct)
|
mapping_meta = load_mapping_meta(conn, acct)
|
||||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||||
# T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
# Validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
||||||
valid_codes = load_nomenclator_codes(conn) or None
|
valid_codes = load_nomenclator_codes(conn) or None
|
||||||
text_rules = load_text_rules(conn, acct)
|
text_rules = load_text_rules(conn, acct)
|
||||||
|
|
||||||
@@ -1049,10 +1040,9 @@ def commit_import(
|
|||||||
toctou_collisions: list[int] = []
|
toctou_collisions: list[int] = []
|
||||||
rows_for_hash: list[str] = []
|
rows_for_hash: list[str] = []
|
||||||
|
|
||||||
# Enqueue in tranzactie explicita (Issue 6)
|
# Enqueue in tranzactie explicita
|
||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
try:
|
try:
|
||||||
# purge_after pentru submissions noi (T16)
|
|
||||||
purge_after_sql = "datetime('now', '+90 days')"
|
purge_after_sql = "datetime('now', '+90 days')"
|
||||||
|
|
||||||
for ok_row in ok_rows:
|
for ok_row in ok_rows:
|
||||||
@@ -1100,7 +1090,7 @@ def commit_import(
|
|||||||
"odometru_final": canon["odometru_final"],
|
"odometru_final": canon["odometru_final"],
|
||||||
})
|
})
|
||||||
|
|
||||||
# Override editat in preview (3.6) — aplicat ULTIMUL, ca in resolver.
|
# Override editat in preview — aplicat ULTIMUL, ca in resolver.
|
||||||
override = ok_row.get("override") or {}
|
override = ok_row.get("override") or {}
|
||||||
if override:
|
if override:
|
||||||
mapped.update(override)
|
mapped.update(override)
|
||||||
@@ -1127,7 +1117,7 @@ def commit_import(
|
|||||||
|
|
||||||
payload_json = json.dumps(mapped, ensure_ascii=False)
|
payload_json = json.dumps(mapped, ensure_ascii=False)
|
||||||
|
|
||||||
# INSERT ON CONFLICT DO NOTHING (TOCTOU — Issue 1)
|
# INSERT ON CONFLICT DO NOTHING (TOCTOU)
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT OR IGNORE INTO submissions "
|
"INSERT OR IGNORE INTO submissions "
|
||||||
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after) "
|
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after) "
|
||||||
@@ -1140,7 +1130,6 @@ def commit_import(
|
|||||||
toctou_collisions.append(row_index)
|
toctou_collisions.append(row_index)
|
||||||
else:
|
else:
|
||||||
sub_id = cur.lastrowid
|
sub_id = cur.lastrowid
|
||||||
# US-010: telemetrie pentru itemii rezolvati prin regula text.
|
|
||||||
_emite_text_rule_hits(conn, acct, int(sub_id), resolved)
|
_emite_text_rule_hits(conn, acct, int(sub_id), resolved)
|
||||||
enqueued.append({
|
enqueued.append({
|
||||||
"submission_id": sub_id,
|
"submission_id": sub_id,
|
||||||
@@ -1155,7 +1144,7 @@ def commit_import(
|
|||||||
|
|
||||||
n_enqueued = len(enqueued)
|
n_enqueued = len(enqueued)
|
||||||
|
|
||||||
# Log atestare (Voce#9): rows_hash + n_confirmed acopera DOAR randurile puse in coada
|
# Log atestare: rows_hash + n_confirmed acopera DOAR randurile puse in coada
|
||||||
rows_hash = hashlib.sha256(
|
rows_hash = hashlib.sha256(
|
||||||
json.dumps(sorted(rows_for_hash), ensure_ascii=False).encode("utf-8")
|
json.dumps(sorted(rows_for_hash), ensure_ascii=False).encode("utf-8")
|
||||||
).hexdigest() if rows_for_hash else ""
|
).hexdigest() if rows_for_hash else ""
|
||||||
@@ -1185,7 +1174,7 @@ def commit_import(
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# POST /v1/import/{id}/rand/{row_index}/editeaza — editare celule preview (3.6) #
|
# POST /v1/import/{id}/rand/{row_index}/editeaza — editare celule preview #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
class RandEditIn(BaseModel):
|
class RandEditIn(BaseModel):
|
||||||
@@ -1205,7 +1194,7 @@ def editeaza_rand(
|
|||||||
req: RandEditIn,
|
req: RandEditIn,
|
||||||
account_id: int = Depends(resolve_account_id),
|
account_id: int = Depends(resolve_account_id),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Persista editarea unui rand de preview (mutatie pura — Approach B, 3.6).
|
"""Persista editarea unui rand de preview (mutatie pura).
|
||||||
|
|
||||||
NU recalculeaza statusul si NU atinge `submissions`; preview-ul rederiva statusul
|
NU recalculeaza statusul si NU atinge `submissions`; preview-ul rederiva statusul
|
||||||
prin `_resolve_row_for_preview` cu override aplicat ultimul.
|
prin `_resolve_row_for_preview` cu override aplicat ultimul.
|
||||||
@@ -1225,7 +1214,7 @@ def editeaza_rand(
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# GET /v1/import/{id}/export-failed — CSV randuri esuate (T8) #
|
# GET /v1/import/{id}/export-failed — CSV randuri esuate #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
_EXPORT_FAILED_COLUMNS = [
|
_EXPORT_FAILED_COLUMNS = [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Router integrare US-001 — endpoint-uri de integrare externe.
|
"""Router integrare — endpoint-uri de integrare externe.
|
||||||
|
|
||||||
Endpointuri:
|
Endpointuri:
|
||||||
GET /v1/ping — readiness check per cont (autentificat sau dev fallback)
|
GET /v1/ping — readiness check per cont (autentificat sau dev fallback)
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
"""API v1 — suprafata gateway (schelet).
|
"""API v1 — suprafata gateway.
|
||||||
|
|
||||||
Endpointuri din plan.md sect. 4. In schelet:
|
Endpointuri:
|
||||||
- POST /v1/prezentari: enqueue cu idempotenta (dedup pe idempotency_key UNIQUE).
|
- POST /v1/prezentari: enqueue cu idempotenta (dedup pe idempotency_key UNIQUE).
|
||||||
- GET /v1/prezentari, /v1/prezentari/{id}: monitorizare coada.
|
- GET /v1/prezentari, /v1/prezentari/{id}: monitorizare coada.
|
||||||
- GET /v1/nomenclator: cache local.
|
- GET /v1/nomenclator: cache local.
|
||||||
- GET /v1/mapari: listare mapari cont.
|
- GET /v1/mapari: listare mapari cont.
|
||||||
Validarea completa (T3), maparea op->cod, auth API-key, redactarea creds in
|
|
||||||
middleware (CORE) si exportul CSV vin ulterior — marcate TODO unde lipsesc.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -79,7 +77,7 @@ def _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, tex
|
|||||||
|
|
||||||
|
|
||||||
def _erori_nemapate(unmapped: list[dict]) -> list[dict]:
|
def _erori_nemapate(unmapped: list[dict]) -> list[dict]:
|
||||||
"""Coduri nemapate imbogatite cu 3 niveluri (COD_NEMAPAT), pentru raspuns onest."""
|
"""Coduri nemapate imbogatite cu 3 niveluri (COD_NEMAPAT)."""
|
||||||
return [
|
return [
|
||||||
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} necunoscut/fara mapare RAR")}
|
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} necunoscut/fara mapare RAR")}
|
||||||
for u in unmapped
|
for u in unmapped
|
||||||
@@ -87,7 +85,7 @@ def _erori_nemapate(unmapped: list[dict]) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def _motiv_clasificare(cl: dict) -> str | None:
|
def _motiv_clasificare(cl: dict) -> str | None:
|
||||||
"""Rezumat uman pe o linie pentru un rezultat de clasificare (PRD 5.7).
|
"""Rezumat uman pe o linie pentru un rezultat de clasificare.
|
||||||
|
|
||||||
None cand status='queued'. Acopera toate ramurile de blocaj: erori de continut
|
None cand status='queued'. Acopera toate ramurile de blocaj: erori de continut
|
||||||
(needs_data), coduri nemapate (needs_mapping) si auto_send oprit (needs_mapping).
|
(needs_data), coduri nemapate (needs_mapping) si auto_send oprit (needs_mapping).
|
||||||
@@ -107,7 +105,7 @@ def _motiv_clasificare(cl: dict) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _rezultat_enqueue(submission_id: int | None, cl: dict, **extra) -> SubmissionResult:
|
def _rezultat_enqueue(submission_id: int | None, cl: dict, **extra) -> SubmissionResult:
|
||||||
"""SubmissionResult onest dintr-un rezultat de clasificare (PRD 5.7).
|
"""SubmissionResult onest dintr-un rezultat de clasificare.
|
||||||
|
|
||||||
Populeaza erori (validare continut), nemapate (coduri fara mapare) si motiv (uman)
|
Populeaza erori (validare continut), nemapate (coduri fara mapare) si motiv (uman)
|
||||||
pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None.
|
pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None.
|
||||||
@@ -141,42 +139,40 @@ def create_prezentari(
|
|||||||
) -> PrezentariResponse:
|
) -> PrezentariResponse:
|
||||||
"""Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission.
|
"""Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission.
|
||||||
|
|
||||||
Validarea de continut (T3, app.validation) ruleaza inainte de enqueue:
|
Validarea de continut (app.validation) ruleaza inainte de enqueue: esecurile NU
|
||||||
esecurile NU resping cererea, ci enqueue-aza cu status `needs_data` + motiv
|
resping cererea, ci enqueue-aza cu status `needs_data` + motiv. JSON malformat ->
|
||||||
(plan.md sect. 3). JSON malformat -> 422 din Pydantic (validare de shape).
|
422 din Pydantic (validare de shape).
|
||||||
account_id vine din cheia API (resolve_account_id): cont real cu cheie,
|
account_id vine din cheia API (resolve_account_id): cont real cu cheie,
|
||||||
implicit id=1 in dev fara cheie, 401 fara cheie valida in prod.
|
implicit id=1 in dev fara cheie, 401 fara cheie valida in prod.
|
||||||
Nota: rar_credentials NU se persista (zero-storage) — worker-ul le va primi
|
|
||||||
pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea.
|
|
||||||
Cand rar_credentials lipseste, submission-ul intra fara creds efemere: worker-ul
|
Cand rar_credentials lipseste, submission-ul intra fara creds efemere: worker-ul
|
||||||
cade pe creds-urile durabile ale contului (`accounts.rar_creds_enc`).
|
cade pe creds-urile durabile ale contului (`accounts.rar_creds_enc`).
|
||||||
"""
|
"""
|
||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
# Creds RAR efemere: criptate si lipite de fiecare submission nou pana la
|
# Creds RAR efemere: criptate si lipite de fiecare submission nou pana la
|
||||||
# primul login reusit pentru cont (worker le sterge atunci). Zero-storage at
|
# primul login reusit pentru cont (worker le sterge atunci). Zero-storage at
|
||||||
# rest — niciodata in clar in DB/loguri (plan sect. 5). Optional: cand lipsesc,
|
# rest — niciodata in clar in DB/loguri. Optional: cand lipsesc,
|
||||||
# creds_enc=NULL si worker-ul foloseste creds-urile durabile ale contului.
|
# creds_enc=NULL si worker-ul foloseste creds-urile durabile ale contului.
|
||||||
creds_enc = encrypt_creds(req.rar_credentials.model_dump()) if req.rar_credentials else None
|
creds_enc = encrypt_creds(req.rar_credentials.model_dump()) if req.rar_credentials else None
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
results: list[SubmissionResult] = []
|
results: list[SubmissionResult] = []
|
||||||
try:
|
try:
|
||||||
# T6/OV-1: load_mapping_meta include auto_send per op (gate pentru coduri noi).
|
# load_mapping_meta include auto_send per op (gate pentru coduri noi).
|
||||||
mapping_meta = load_mapping_meta(conn, acct)
|
mapping_meta = load_mapping_meta(conn, acct)
|
||||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||||
# Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat.
|
# Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat.
|
||||||
# valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot).
|
# valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot).
|
||||||
valid_codes = load_nomenclator_codes(conn) or None
|
valid_codes = load_nomenclator_codes(conn) or None
|
||||||
# Reguli text incarcate o data per cerere (seam partajat cu dry-run, invariant 5.2).
|
# Reguli text incarcate o data per cerere (seam partajat cu dry-run).
|
||||||
text_rules = load_text_rules(conn, acct)
|
text_rules = load_text_rules(conn, acct)
|
||||||
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
|
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
|
||||||
for prez in req.prezentari:
|
for prez in req.prezentari:
|
||||||
content = prez.model_dump()
|
content = prez.model_dump()
|
||||||
# T9/OV-2: canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).
|
# canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).
|
||||||
# build_key aplica account_or_default(account_id) inainte de hash:
|
# build_key aplica account_or_default(account_id) inainte de hash:
|
||||||
# None si 1 colapseaza la aceeasi cheie (canal API + canal import).
|
# None si 1 colapseaza la aceeasi cheie (canal API + canal import).
|
||||||
canon = canonicalize_row(content)
|
canon = canonicalize_row(content)
|
||||||
key = build_key(account_id, canon)
|
key = build_key(account_id, canon)
|
||||||
# Aplica normalizarea si in content (odometru canonicalizat inainte de validare, §3.4bis)
|
# Aplica normalizarea si in content (odometru canonicalizat inainte de validare)
|
||||||
content.update({
|
content.update({
|
||||||
"vin": canon["vin"],
|
"vin": canon["vin"],
|
||||||
"nr_inmatriculare": canon["nr_inmatriculare"],
|
"nr_inmatriculare": canon["nr_inmatriculare"],
|
||||||
@@ -187,7 +183,7 @@ def create_prezentari(
|
|||||||
(key,),
|
(key,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
# US-012: un rand `error` (ex. creds RAR gresite) NU mai blocheaza tacit
|
# Un rand `error` (ex. creds RAR gresite) NU mai blocheaza tacit
|
||||||
# retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam
|
# retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam
|
||||||
# creds + reset), printr-un UPDATE compare-and-swap pe status='error'.
|
# creds + reset), printr-un UPDATE compare-and-swap pe status='error'.
|
||||||
if existing["status"] == "error":
|
if existing["status"] == "error":
|
||||||
@@ -205,17 +201,16 @@ def create_prezentari(
|
|||||||
cl["rar_error"], creds_enc, existing["id"]),
|
cl["rar_error"], creds_enc, existing["id"]),
|
||||||
)
|
)
|
||||||
if cur.rowcount == 1:
|
if cur.rowcount == 1:
|
||||||
# Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc,
|
# Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc)
|
||||||
# decizie #17) — ambele canale converg pe parola corectata.
|
# — ambele canale converg pe parola corectata.
|
||||||
if req.rar_credentials is not None:
|
if req.rar_credentials is not None:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
|
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
|
||||||
(encrypt_creds(req.rar_credentials.model_dump()), acct),
|
(encrypt_creds(req.rar_credentials.model_dump()), acct),
|
||||||
)
|
)
|
||||||
# US-010: telemetrie pentru itemii rezolvati prin regula text.
|
|
||||||
_emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"])
|
_emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"])
|
||||||
# Raspuns onest si la reactivare (PRD 5.7): daca re-clasificarea
|
# Raspuns onest si la reactivare: daca re-clasificarea cade pe
|
||||||
# cade pe needs_data/needs_mapping, expune motivul (nu doar status).
|
# needs_data/needs_mapping, expune motivul (nu doar status).
|
||||||
results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True))
|
results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True))
|
||||||
continue
|
continue
|
||||||
# Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE
|
# Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE
|
||||||
@@ -234,7 +229,7 @@ def create_prezentari(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Helper pur partajat cu dry-run (PRD 5.2): reproduce EXACT clasificarea
|
# Helper pur partajat cu dry-run: reproduce EXACT clasificarea
|
||||||
# (canonicalize + mapare op->cod + validare + auto_send gate).
|
# (canonicalize + mapare op->cod + validare + auto_send gate).
|
||||||
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
||||||
if cl["blocked_error"]:
|
if cl["blocked_error"]:
|
||||||
@@ -247,13 +242,12 @@ def create_prezentari(
|
|||||||
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc),
|
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc),
|
||||||
)
|
)
|
||||||
sub_id = int(cur.lastrowid)
|
sub_id = int(cur.lastrowid)
|
||||||
# US-010: telemetrie pentru itemii rezolvati prin regula text.
|
|
||||||
_emite_text_rule_hits(conn, acct, sub_id, cl["resolved"])
|
_emite_text_rule_hits(conn, acct, sub_id, cl["resolved"])
|
||||||
# Raspuns onest (PRD 5.7): pe needs_data/needs_mapping expune erori/nemapate/motiv.
|
# Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv.
|
||||||
results.append(_rezultat_enqueue(sub_id, cl))
|
results.append(_rezultat_enqueue(sub_id, cl))
|
||||||
|
|
||||||
# US-004: audit cerere API per cont. Doar metadate (count + distributie status),
|
# Audit cerere API per cont. Doar metadate (count + distributie status),
|
||||||
# NICIUN camp de payload PII integral. Reuse conn (T4 — fara contentie WAL).
|
# NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL).
|
||||||
dist: dict[str, int] = {}
|
dist: dict[str, int] = {}
|
||||||
for r in results:
|
for r in results:
|
||||||
if r.reactivated:
|
if r.reactivated:
|
||||||
@@ -284,7 +278,7 @@ def valideaza_prezentari(
|
|||||||
|
|
||||||
Intoarce pentru fiecare prezentare: verdictul (status_estimat), erorile de
|
Intoarce pentru fiecare prezentare: verdictul (status_estimat), erorile de
|
||||||
continut si codurile nemapate — exact ce ar obtine trimiterea reala pe acelasi
|
continut si codurile nemapate — exact ce ar obtine trimiterea reala pe acelasi
|
||||||
payload + aceeasi mapare de cont. rar_credentials ignorat complet (PRD 5.2).
|
payload + aceeasi mapare de cont. rar_credentials ignorat complet.
|
||||||
"""
|
"""
|
||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -301,7 +295,7 @@ def valideaza_prezentari(
|
|||||||
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
||||||
if res["blocked_error"]:
|
if res["blocked_error"]:
|
||||||
res = {**res, "status": "error"}
|
res = {**res, "status": "error"}
|
||||||
# US-003: imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
|
# Imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
|
||||||
nemapate = [
|
nemapate = [
|
||||||
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} fara mapare RAR")}
|
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} fara mapare RAR")}
|
||||||
for u in res["unmapped"]
|
for u in res["unmapped"]
|
||||||
@@ -329,7 +323,7 @@ def list_prezentari(
|
|||||||
try:
|
try:
|
||||||
scope_sql, scope_params = account_scope_clause(account_id)
|
scope_sql, scope_params = account_scope_clause(account_id)
|
||||||
# payload_json e plaintext (vezi submissions.payload_json); il citim doar ca
|
# payload_json e plaintext (vezi submissions.payload_json); il citim doar ca
|
||||||
# sa derivam campurile afisabile prin helper-ul partajat (US-003, DRY), nu il expunem.
|
# sa derivam campurile afisabile prin helper-ul partajat, nu il expunem.
|
||||||
cols = (
|
cols = (
|
||||||
"id, status, id_prezentare, rar_status_code, retry_count, "
|
"id, status, id_prezentare, rar_status_code, retry_count, "
|
||||||
"created_at, updated_at, payload_json"
|
"created_at, updated_at, payload_json"
|
||||||
@@ -357,13 +351,13 @@ def list_prezentari(
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
# Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita (B4).
|
# Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita.
|
||||||
# Exclude: rar_creds_enc, payload_json, idempotency_key, rar_error, sending_since.
|
# Exclude: rar_creds_enc, payload_json, idempotency_key, sending_since.
|
||||||
_PREZENTARE_FIELDS = frozenset({
|
_PREZENTARE_FIELDS = frozenset({
|
||||||
"id", "status", "id_prezentare", "rar_status_code", "retry_count",
|
"id", "status", "id_prezentare", "rar_status_code", "retry_count",
|
||||||
"next_attempt_at", "created_at", "updated_at", "account_id",
|
"next_attempt_at", "created_at", "updated_at", "account_id",
|
||||||
"batch_id", "row_index", "purge_after",
|
"batch_id", "row_index", "purge_after",
|
||||||
# T9: rar_error e SIGUR de expus — contine doar coduri/mesaje de validare RAR si
|
# rar_error e SIGUR de expus — contine doar coduri/mesaje de validare RAR si
|
||||||
# erori din catalog (niciodata creds, ex. RAR_CREDS_INVALIDE poarta doar cauza
|
# erori din catalog (niciodata creds, ex. RAR_CREDS_INVALIDE poarta doar cauza
|
||||||
# "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API.
|
# "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API.
|
||||||
"rar_error",
|
"rar_error",
|
||||||
@@ -383,7 +377,7 @@ def get_prezentare(
|
|||||||
[submission_id] + scope_params,
|
[submission_id] + scope_params,
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
# B3: acelasi mesaj indiferent daca randul exista dar apartine altui cont
|
# Acelasi mesaj indiferent daca randul exista dar apartine altui cont
|
||||||
# sau nu exista deloc — nu confirmam existenta.
|
# sau nu exista deloc — nu confirmam existenta.
|
||||||
raise HTTPException(status_code=404, detail="submission inexistent")
|
raise HTTPException(status_code=404, detail="submission inexistent")
|
||||||
row_dict = dict(row)
|
row_dict = dict(row)
|
||||||
@@ -397,11 +391,11 @@ def delete_prezentare(
|
|||||||
submission_id: int,
|
submission_id: int,
|
||||||
account_id: int = Depends(resolve_account_id),
|
account_id: int = Depends(resolve_account_id),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Sterge o trimitere blocata a contului cheii API (US-010).
|
"""Sterge o trimitere blocata a contului cheii API.
|
||||||
|
|
||||||
Raspuns 200 + body JSON (NU 204 — clienti VFP fac string-parse). Scope evaluat
|
Raspuns 200 + body JSON (NU 204 — clienti VFP fac string-parse). Scope evaluat
|
||||||
INAINTEA starii (decizie /autoplan #20): cross-account / inexistent -> 404 (acelasi
|
INAINTEA starii: cross-account / inexistent -> 404 (acelasi mesaj);
|
||||||
mesaj, B3); own-account `sent`/`sending` -> 409 (conflict de stare).
|
own-account `sent`/`sending` -> 409 (conflict de stare).
|
||||||
"""
|
"""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
@@ -424,10 +418,10 @@ def repune_prezentare(
|
|||||||
submission_id: int,
|
submission_id: int,
|
||||||
account_id: int = Depends(resolve_account_id),
|
account_id: int = Depends(resolve_account_id),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Re-pune in coada o trimitere blocata a contului cheii API (US-010).
|
"""Re-pune in coada o trimitere blocata a contului cheii API.
|
||||||
|
|
||||||
`error -> queued` (peste helper US-009), re-ruleaza classify. Acelasi oracol de
|
`error -> queued`, re-ruleaza classify. Acelasi oracol de scope/stare ca DELETE
|
||||||
scope/stare ca DELETE (404 cross-account/inexistent, 409 sent/sending).
|
(404 cross-account/inexistent, 409 sent/sending).
|
||||||
"""
|
"""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
@@ -478,8 +472,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a
|
|||||||
"""Randuri audit filtrate pe cont + data(updated_at) in [from, to].
|
"""Randuri audit filtrate pe cont + data(updated_at) in [from, to].
|
||||||
|
|
||||||
account_id = contul cheii API (scope obligatoriu — PII in CSV). Randuri cu
|
account_id = contul cheii API (scope obligatoriu — PII in CSV). Randuri cu
|
||||||
account_id IS NULL apartin contului 1 (legacy/OV-2). payload_json e text in
|
account_id IS NULL apartin contului 1. b64_image NU intra in CSV.
|
||||||
schelet; b64_image NU intra in CSV.
|
|
||||||
"""
|
"""
|
||||||
scope_sql, scope_params = account_scope_clause(account_id)
|
scope_sql, scope_params = account_scope_clause(account_id)
|
||||||
sql = (
|
sql = (
|
||||||
@@ -514,7 +507,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a
|
|||||||
"submission_id": r["id"],
|
"submission_id": r["id"],
|
||||||
"status": r["status"],
|
"status": r["status"],
|
||||||
"id_prezentare": r["id_prezentare"] or "",
|
"id_prezentare": r["id_prezentare"] or "",
|
||||||
# NULL→cont 1 (OV-2): coloana reflecta invariantul de scope, nu "" ambiguu.
|
# NULL→cont 1: coloana reflecta invariantul de scope, nu "" ambiguu.
|
||||||
"account_id": account_or_default(r["account_id"]),
|
"account_id": account_or_default(r["account_id"]),
|
||||||
"vin": p.get("vin") or "",
|
"vin": p.get("vin") or "",
|
||||||
"nr_inmatriculare": p.get("nr_inmatriculare") or "",
|
"nr_inmatriculare": p.get("nr_inmatriculare") or "",
|
||||||
@@ -539,7 +532,7 @@ def audit_export(
|
|||||||
|
|
||||||
pe data(updated_at). `status` implicit `sent` (ce a ajuns efectiv la RAR);
|
pe data(updated_at). `status` implicit `sent` (ce a ajuns efectiv la RAR);
|
||||||
`status=all` exporta toata coada contului. Leaga retinerea 90 zile prin coloana
|
`status=all` exporta toata coada contului. Leaga retinerea 90 zile prin coloana
|
||||||
`purge_after` (plan.md sect. 4 + 8). b64_image nu se exporta.
|
`purge_after`. b64_image nu se exporta.
|
||||||
"""
|
"""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
@@ -568,7 +561,7 @@ def get_mapari(
|
|||||||
"""Maparile operatie->cod ale contului curent.
|
"""Maparile operatie->cod ale contului curent.
|
||||||
|
|
||||||
Parametrul `account_id` din query e pastrat pentru compatibilitate, dar contul
|
Parametrul `account_id` din query e pastrat pentru compatibilitate, dar contul
|
||||||
efectiv vine MEREU din cheia API (TD-3.2). Daca e prezent si difera -> 400.
|
efectiv vine MEREU din cheia API. Daca e prezent si difera -> 400.
|
||||||
"""
|
"""
|
||||||
if account_id is not None and account_id != key_account:
|
if account_id is not None and account_id != key_account:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -635,7 +628,7 @@ def create_mapare(
|
|||||||
|
|
||||||
|
|
||||||
class RarCredsIn(BaseModel):
|
class RarCredsIn(BaseModel):
|
||||||
"""Creds RAR durabile per-cont (D4). Stocate criptate (Fernet) in accounts.rar_creds_enc."""
|
"""Creds RAR durabile per-cont. Stocate criptate (Fernet) in accounts.rar_creds_enc."""
|
||||||
|
|
||||||
email: str = Field(..., min_length=1)
|
email: str = Field(..., min_length=1)
|
||||||
password: str = Field(..., min_length=1, repr=False)
|
password: str = Field(..., min_length=1, repr=False)
|
||||||
@@ -646,7 +639,7 @@ def set_rar_creds(
|
|||||||
req: RarCredsIn,
|
req: RarCredsIn,
|
||||||
account_id: int = Depends(resolve_account_id),
|
account_id: int = Depends(resolve_account_id),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Seteaza creds RAR durabile per-cont (D4/T1).
|
"""Seteaza creds RAR durabile per-cont.
|
||||||
|
|
||||||
Criptate Fernet in accounts.rar_creds_enc. Worker-ul le foloseste ca fallback
|
Criptate Fernet in accounts.rar_creds_enc. Worker-ul le foloseste ca fallback
|
||||||
cand submission-ul nu mai are creds (canal web fara re-pusher, restart worker).
|
cand submission-ul nu mai are creds (canal web fara re-pusher, restart worker).
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ def _extract_key(x_api_key: str | None, authorization: str | None) -> str | None
|
|||||||
|
|
||||||
|
|
||||||
def _log_auth_esuat(request: Request | None, plaintext: str | None, motiv: str) -> None:
|
def _log_auth_esuat(request: Request | None, plaintext: str | None, motiv: str) -> None:
|
||||||
"""Eveniment de jurnal pentru un esec de auth (US-004): IP + prefix cheie, NU cheia.
|
"""Eveniment de jurnal pentru un esec de auth: IP + prefix cheie, NU cheia.
|
||||||
|
|
||||||
Best-effort (log_event inghite erorile). Import local: evita cuplarea la import-time
|
Best-effort (log_event inghite erorile). Import local: evita cuplarea la import-time
|
||||||
(observ -> db; auth -> db) si pastreaza auth.py importabil din CLI fara efecte.
|
(observ -> db; auth -> db) si pastreaza auth.py importabil din CLI fara efecte.
|
||||||
@@ -142,7 +142,7 @@ def resolve_account_id(
|
|||||||
- cheie invalida (prezenta) -> 401 (mereu, indiferent de flag)
|
- cheie invalida (prezenta) -> 401 (mereu, indiferent de flag)
|
||||||
- fara cheie + flag off -> cont implicit (id=1), back-compat
|
- fara cheie + flag off -> cont implicit (id=1), back-compat
|
||||||
- fara cheie + flag on -> 401
|
- fara cheie + flag on -> 401
|
||||||
Esecurile de auth (401) emit `api_auth_esuat` cu IP + prefix cheie (US-004).
|
Esecurile de auth (401) emit `api_auth_esuat` cu IP + prefix cheie.
|
||||||
"""
|
"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
plaintext = _extract_key(x_api_key, authorization)
|
plaintext = _extract_key(x_api_key, authorization)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Configurare gateway. Env vars (prefix AUTOPASS_) + valori implicite.
|
"""Configurare gateway. Env vars (prefix AUTOPASS_) + valori implicite.
|
||||||
|
|
||||||
NU stocheaza parole RAR. Credentialele RAR vin per-cerere de la ROAAUTO
|
NU stocheaza parole RAR. Credentialele RAR vin per-cerere de la ROAAUTO.
|
||||||
(vezi plan.md sect. 5). Helper-ul `load_test_credentials` citeste blocul
|
Helper-ul `load_test_credentials` citeste blocul <test> din settings.xml DOAR
|
||||||
<test> din settings.xml DOAR pentru dev local / probe pe mediul de test.
|
pentru dev local / probe pe mediul de test.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,22 +22,21 @@ class Settings(BaseSettings):
|
|||||||
# --- Bază de date ---
|
# --- Bază de date ---
|
||||||
db_path: Path = ROOT / "data" / "autopass.db"
|
db_path: Path = ROOT / "data" / "autopass.db"
|
||||||
|
|
||||||
# --- Observabilitate / jurnal aplicatie (PRD 5.6) ---
|
# --- Observabilitate / jurnal aplicatie ---
|
||||||
# Nivel minim al evenimentelor scrise in app_events + log text. Sub el, evenimentul
|
# Nivel minim al evenimentelor scrise in app_events + log text. Sub el, evenimentul
|
||||||
# e ignorat (best-effort). DEBUG|INFO|WARNING|ERROR|CRITICAL.
|
# e ignorat (best-effort). DEBUG|INFO|WARNING|ERROR|CRITICAL.
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
# Retentie jurnal (app_events) — aliniat cu submissions/import_batches (decizie §5).
|
|
||||||
log_retention_days: int = 90
|
log_retention_days: int = 90
|
||||||
# Director pentru log-ul text rotativ (RotatingFileHandler in aplicatie, decizie §5).
|
# Director pentru log-ul text rotativ (RotatingFileHandler in aplicatie).
|
||||||
# Fisier per-proces (app-api.log / app-worker.log) — rotatia nu e multiproces-safe.
|
# Fisier per-proces (app-api.log / app-worker.log) — rotatia nu e multiproces-safe.
|
||||||
log_dir: Path = ROOT / ".run"
|
log_dir: Path = ROOT / ".run"
|
||||||
log_file_max_bytes: int = 5_000_000
|
log_file_max_bytes: int = 5_000_000
|
||||||
log_file_backup_count: int = 5
|
log_file_backup_count: int = 5
|
||||||
# Retentie randuri blocate (error/needs_data/needs_mapping). Mai scurt decat 90z
|
# Retentie randuri blocate (error/needs_data/needs_mapping). Mai scurt decat 90z
|
||||||
# ale `sent` — un blocat n-are valoare de audit (decizie §5).
|
# ale `sent` — un blocat n-are valoare de audit.
|
||||||
blocked_retention_days: int = 30
|
blocked_retention_days: int = 30
|
||||||
|
|
||||||
# --- Securitate (CORE) ---
|
# --- Securitate ---
|
||||||
# Enforcement auth API-key pe /v1/* protejat. False (dev/test): fara cheie ->
|
# Enforcement auth API-key pe /v1/* protejat. False (dev/test): fara cheie ->
|
||||||
# cont implicit id=1. True (prod): fara cheie valida -> 401. O cheie PREZENTA
|
# cont implicit id=1. True (prod): fara cheie valida -> 401. O cheie PREZENTA
|
||||||
# dar invalida da 401 indiferent de flag.
|
# dar invalida da 401 indiferent de flag.
|
||||||
@@ -49,29 +48,28 @@ class Settings(BaseSettings):
|
|||||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
creds_key: str | None = None
|
creds_key: str | None = None
|
||||||
|
|
||||||
# --- Sesiuni web (US-002, PRD 3.3) ---
|
# --- Sesiuni web ---
|
||||||
# Secret semnat cookie sesiune. None -> efemer la fiecare restart (dev ok;
|
# Secret semnat cookie sesiune. None -> efemer la fiecare restart (dev ok;
|
||||||
# in prod seteaza persistent ca si creds_key, altfel cookieurile se invalideaza
|
# in prod seteaza persistent ca si creds_key, altfel cookieurile se invalideaza
|
||||||
# la restart). Genereaza: python -c "import secrets; print(secrets.token_hex(32))"
|
# la restart). Genereaza: python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
session_secret: str | None = None
|
session_secret: str | None = None
|
||||||
# True (IMPLICIT, sigur pentru prod): rutele web fara sesiune -> redirect /login;
|
# True (IMPLICIT, sigur pentru prod): rutele web fara sesiune -> redirect /login;
|
||||||
# CSRF enforce. Pentru dev rapid pe contul implicit id=1 (back-compat C12/§5 Q5),
|
# CSRF enforce. Pentru dev rapid pe contul implicit id=1,
|
||||||
# seteaza explicit AUTOPASS_WEB_AUTH_REQUIRED=false.
|
# seteaza explicit AUTOPASS_WEB_AUTH_REQUIRED=false.
|
||||||
web_auth_required: bool = True
|
web_auth_required: bool = True
|
||||||
# True (prod, in spatele Cloudflare Tunnel TLS): cookie cu Secure flag (C4).
|
# True (prod, in spatele Cloudflare Tunnel TLS): cookie cu Secure flag.
|
||||||
# False (dev): cookie fara Secure, functioneaza pe HTTP.
|
# False (dev): cookie fara Secure, functioneaza pe HTTP.
|
||||||
session_https_only: bool = False
|
session_https_only: bool = False
|
||||||
|
|
||||||
# --- Notificare email admin la signup (US-012, PRD 3.3b) ---
|
# --- Notificare email admin la signup ---
|
||||||
# Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP);
|
# Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP).
|
||||||
# follow-up cand exista SMTP real configurat in .env.
|
|
||||||
smtp_host: str | None = None
|
smtp_host: str | None = None
|
||||||
smtp_port: int = 587
|
smtp_port: int = 587
|
||||||
smtp_user: str | None = None
|
smtp_user: str | None = None
|
||||||
smtp_password: str | None = None
|
smtp_password: str | None = None
|
||||||
smtp_from: str | None = None
|
smtp_from: str | None = None
|
||||||
|
|
||||||
# --- Rate-limit signup + login (US-009, PRD 3.3 C5) ---
|
# --- Rate-limit signup + login ---
|
||||||
# Max cereri POST /signup per IP in fereastra de timp (in-proces, fara dependinta noua).
|
# Max cereri POST /signup per IP in fereastra de timp (in-proces, fara dependinta noua).
|
||||||
signup_rate_max: int = 5
|
signup_rate_max: int = 5
|
||||||
signup_rate_window_s: int = 3600
|
signup_rate_window_s: int = 3600
|
||||||
@@ -83,25 +81,23 @@ class Settings(BaseSettings):
|
|||||||
rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass"
|
rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass"
|
||||||
rar_base_url_prod: str = "https://apps.rarom.ro/rar-autopass"
|
rar_base_url_prod: str = "https://apps.rarom.ro/rar-autopass"
|
||||||
|
|
||||||
# WAF-ul RAR da 403 fara User-Agent de browser (confirmat live, vezi
|
# WAF-ul RAR da 403 fara User-Agent de browser. Toate apelurile httpx il trimit.
|
||||||
# docs/api-rar-contract.md). Toate apelurile httpx il trimit.
|
|
||||||
http_user_agent: str = "Mozilla/5.0"
|
http_user_agent: str = "Mozilla/5.0"
|
||||||
http_timeout_s: float = 30.0
|
http_timeout_s: float = 30.0
|
||||||
|
|
||||||
# --- Worker ---
|
# --- Worker ---
|
||||||
worker_poll_interval_s: float = 5.0
|
worker_poll_interval_s: float = 5.0
|
||||||
worker_heartbeat_stale_s: int = 30 # /healthz considera worker-ul mort peste atat
|
worker_heartbeat_stale_s: int = 30 # /healthz considera worker-ul mort peste atat
|
||||||
# In schelet send-ul e DEZACTIVAT (nu trimite la RAR). Activeaza-l explicit
|
# Send DEZACTIVAT implicit (nu trimite la RAR). Activeaza-l explicit pentru
|
||||||
# pentru proba end-to-end. Reconcilierea/retry-ul complet = T2.
|
# proba end-to-end.
|
||||||
worker_send_enabled: bool = False
|
worker_send_enabled: bool = False
|
||||||
# Dev: foloseste creds <test> din settings.xml pt login worker. In productie
|
# Dev: foloseste creds <test> din settings.xml pt login worker. In productie
|
||||||
# creds vin per-cerere de la ROAAUTO (T2) — lasa False.
|
# creds vin per-cerere de la ROAAUTO — lasa False.
|
||||||
worker_use_test_creds: bool = False
|
worker_use_test_creds: bool = False
|
||||||
# T2 — recuperare orfane + retry/backoff:
|
|
||||||
worker_sending_lease_s: int = 120 # rand 'sending' mai vechi de atat = orfan (worker mort mid-POST)
|
worker_sending_lease_s: int = 120 # rand 'sending' mai vechi de atat = orfan (worker mort mid-POST)
|
||||||
worker_retry_base_s: int = 5 # backoff = base * 2^retry (plafonat la max)
|
worker_retry_base_s: int = 5 # backoff = base * 2^retry (plafonat la max)
|
||||||
worker_retry_max_s: int = 300
|
worker_retry_max_s: int = 300
|
||||||
worker_max_retries: int = 8 # peste atat -> error + banner (pana persistenta)
|
worker_max_retries: int = 8 # peste atat -> error + banner
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rar_base_url(self) -> str:
|
def rar_base_url(self) -> str:
|
||||||
|
|||||||
14
app/db.py
14
app/db.py
@@ -61,13 +61,13 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
if "rar_creds_enc" not in acc_cols:
|
if "rar_creds_enc" not in acc_cols:
|
||||||
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_enc TEXT")
|
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_enc TEXT")
|
||||||
if "active" not in acc_cols:
|
if "active" not in acc_cols:
|
||||||
# Conturi existente raman active (default 1). Lifecycle consumat de 3.3.
|
# Conturi existente raman active (default 1).
|
||||||
conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1")
|
conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1")
|
||||||
acc_cols.add("active")
|
acc_cols.add("active")
|
||||||
if "status" not in acc_cols:
|
if "status" not in acc_cols:
|
||||||
# Stare de ciclu de viata (5.5). Defensiv idempotent (ca is_admin in 3.3b).
|
# Stare de ciclu de viata. Default 'active' (trece CHECK pe randurile existente),
|
||||||
# Default 'active' (trece CHECK pe randurile existente), apoi derivam din `active`:
|
# apoi derivam din `active`: active=0 -> 'pending'.
|
||||||
# active=0 -> 'pending'. Invariant: active=1 <=> status='active'.
|
# Invariant: active=1 <=> status='active'.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"ALTER TABLE accounts ADD COLUMN status TEXT NOT NULL DEFAULT 'active' "
|
"ALTER TABLE accounts ADD COLUMN status TEXT NOT NULL DEFAULT 'active' "
|
||||||
"CHECK (status IN ('pending','active','blocked','archived','deleted'))"
|
"CHECK (status IN ('pending','active','blocked','archived','deleted'))"
|
||||||
@@ -97,9 +97,7 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
if "email_verified" not in user_cols:
|
if "email_verified" not in user_cols:
|
||||||
conn.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0")
|
conn.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0")
|
||||||
|
|
||||||
# Coloana import_rows.override_json (3.6, Approach B): patch canonic editat in
|
# Coloana import_rows.override_json: patch canonic editat in preview, criptat Fernet.
|
||||||
# preview, criptat Fernet. Defensiv idempotent (ca is_admin in 3.3b) — DB create
|
|
||||||
# inainte de 3.6 nu au coloana.
|
|
||||||
irows_tbl = conn.execute(
|
irows_tbl = conn.execute(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='import_rows'"
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='import_rows'"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@@ -151,7 +149,7 @@ def queue_depth(conn: sqlite3.Connection) -> int:
|
|||||||
return int(row["n"]) if row else 0
|
return int(row["n"]) if row else 0
|
||||||
|
|
||||||
|
|
||||||
# --- Jurnal de aplicatie (app_events, PRD 5.6 US-003) ---
|
# --- Jurnal de aplicatie (app_events) ---
|
||||||
|
|
||||||
def insert_app_event(
|
def insert_app_event(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Helper notificare email admin la signup (US-012, PRD 3.3b).
|
"""Helper notificare email admin la signup.
|
||||||
|
|
||||||
Livrare DEGRADATA: daca smtp_host nu e configurat, functia e no-op (log doar).
|
Livrare DEGRADATA: daca smtp_host nu e configurat, functia e no-op (log doar).
|
||||||
Orice eroare SMTP e prinsa si logata — signup-ul NU e blocat niciodata.
|
Orice eroare SMTP e prinsa si logata — signup-ul NU e blocat niciodata.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Catalog central de erori AutoPass (PRD 5.4).
|
"""Catalog central de erori AutoPass.
|
||||||
|
|
||||||
Singura sursa de adevar care mapeaza fiecare cod de eroare la (problema, fix),
|
Singura sursa de adevar care mapeaza fiecare cod de eroare la (problema, fix),
|
||||||
cu un helper care construieste obiectul de eroare pe 3 niveluri:
|
cu un helper care construieste obiectul de eroare pe 3 niveluri:
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
"""Cheie de idempotenta = hash de continut canonic.
|
"""Cheie de idempotenta = hash de continut canonic.
|
||||||
|
|
||||||
RAR nu are camp nr. comanda si accepta duplicate -> dedup-ul e in sarcina noastra
|
RAR nu are camp nr. comanda si accepta duplicate -> dedup-ul e in sarcina noastra.
|
||||||
(plan.md sect. 14). Hash stabil peste o reprezentare canonica a prezentarii.
|
Hash stabil peste o reprezentare canonica a prezentarii.
|
||||||
|
|
||||||
Treapta 2 (T9 + OV-2): extrage canonicalize_row + build_key ca helpere publice
|
canonicalize_row + build_key sunt helpere publice partajate intre canalul API si
|
||||||
partajate intre canalul API si canalul import.
|
canalul import:
|
||||||
- canonicalize_row: normeaza VIN/nr/odometru (strip ".0" Excel coercion) INAINTE
|
- canonicalize_row: normeaza VIN/nr/odometru (strip ".0" Excel coercion) INAINTE
|
||||||
de validare (§3.4bis) si INAINTE de cheie.
|
de validare si INAINTE de cheie.
|
||||||
- build_key: aplica account_or_default INAINTE de hash (None si 1 => o cheie).
|
- build_key: aplica account_or_default INAINTE de hash (None si 1 => o cheie).
|
||||||
|
Altfel acelasi rand logic din canale diferite (account_id None pe canalul API,
|
||||||
|
1 pe import) ar primi chei diferite -> al doilea FINALIZATA duplicat.
|
||||||
|
|
||||||
OV-2 — skew account_id: routerul vechi pasa account_id AS-PASSED (None pe canal API
|
Randuri vechi cu cheie-None nu sunt gasite de build_key nou: dual-lookup la
|
||||||
fara auth). Randurile se stocau sub account_or_default=1, dar cheia includea None.
|
already_sent (cheia noua, apoi build_key_legacy) sau recompute-keys o singura data.
|
||||||
Acelasi rand logic din import (account_id=1) dadea cheie diferita -> already_sent
|
|
||||||
rata -> al doilea FINALIZATA. Fix: build_key normalizeaza INTOTDEAUNA la
|
|
||||||
account_or_default inainte de hash.
|
|
||||||
|
|
||||||
Migrare DB productie (OV-2): randurile existente cu cheie-None nu mai sunt gasite de
|
|
||||||
build_key nou. Strategie documentata: dual-lookup la already_sent (incearca cheia
|
|
||||||
noua, apoi cheia legacy). In dev nu exista date reale; la first-deploy productie
|
|
||||||
se poate face recompute-keys o singura data.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -46,10 +40,7 @@ def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]:
|
|||||||
- data_prestatie: strip (normalizarea la YYYY-MM-DD se face in parser).
|
- data_prestatie: strip (normalizarea la YYYY-MM-DD se face in parser).
|
||||||
- prestatii: pastrate ca-atare (rezolvarea e in resolve_prestatii).
|
- prestatii: pastrate ca-atare (rezolvarea e in resolve_prestatii).
|
||||||
"""
|
"""
|
||||||
# VIN
|
|
||||||
vin = (raw.get("vin") or "").strip().upper()
|
vin = (raw.get("vin") or "").strip().upper()
|
||||||
|
|
||||||
# Nr. inmatriculare
|
|
||||||
nr = (raw.get("nr_inmatriculare") or "").strip().upper()
|
nr = (raw.get("nr_inmatriculare") or "").strip().upper()
|
||||||
|
|
||||||
# Odometru: strip ".0" Excel float coercion
|
# Odometru: strip ".0" Excel float coercion
|
||||||
@@ -82,8 +73,8 @@ def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]:
|
|||||||
def build_key(account_id: int | None, canon: dict[str, Any]) -> str:
|
def build_key(account_id: int | None, canon: dict[str, Any]) -> str:
|
||||||
"""SHA-256 partajat canal-API + canal-import.
|
"""SHA-256 partajat canal-API + canal-import.
|
||||||
|
|
||||||
Aplica account_or_default inainte de hash (OV-2): None si 1 colapseaza la
|
Aplica account_or_default inainte de hash: None si 1 colapseaza la aceeasi
|
||||||
aceeasi cheie => acelasi rand logic din canale diferite nu se trimite de doua ori.
|
cheie => acelasi rand logic din canale diferite nu se trimite de doua ori.
|
||||||
"""
|
"""
|
||||||
# Import local ca sa evitam import circular (mapping importa din idempotency via validator)
|
# Import local ca sa evitam import circular (mapping importa din idempotency via validator)
|
||||||
from .mapping import account_or_default
|
from .mapping import account_or_default
|
||||||
@@ -106,8 +97,8 @@ def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
|||||||
Wrapper backward-compat peste canonicalize_row + build_key.
|
Wrapper backward-compat peste canonicalize_row + build_key.
|
||||||
Exclude obs si b64Image (cosmetice, nu definesc unicitatea declaratiei).
|
Exclude obs si b64Image (cosmetice, nu definesc unicitatea declaratiei).
|
||||||
|
|
||||||
NOTA: dupa OV-2, account_id=None si account_id=1 produc ACEEASI cheie
|
NOTA: account_id=None si account_id=1 produc ACEEASI cheie (via
|
||||||
(via account_or_default in build_key). Randuri vechi cu cheie-None nu sunt
|
account_or_default in build_key). Randuri vechi cu cheie-None nu sunt
|
||||||
acoperite automat — dual-lookup sau recompute-keys la migrare productie.
|
acoperite automat — dual-lookup sau recompute-keys la migrare productie.
|
||||||
"""
|
"""
|
||||||
canon = canonicalize_row(prezentare)
|
canon = canonicalize_row(prezentare)
|
||||||
@@ -117,8 +108,8 @@ def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
|||||||
def build_key_legacy(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
def build_key_legacy(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
||||||
"""Cheia in formatul vechi (account_id AS-PASSED, fara canonicalize).
|
"""Cheia in formatul vechi (account_id AS-PASSED, fara canonicalize).
|
||||||
|
|
||||||
Folosita EXCLUSIV pentru dual-lookup la already_sent pe DB cu randuri vechi
|
Folosita EXCLUSIV pentru dual-lookup la already_sent pe DB cu randuri vechi.
|
||||||
(dinainte de T9). Nu folosi pentru randuri noi.
|
Nu folosi pentru randuri noi.
|
||||||
"""
|
"""
|
||||||
canonic = {
|
canonic = {
|
||||||
"account_id": account_id,
|
"account_id": account_id,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Parser fisiere xlsx/csv pentru import prezentari (Treapta 2, U1).
|
"""Parser fisiere xlsx/csv pentru import prezentari (Treapta 2).
|
||||||
|
|
||||||
Arhitectura 2-treceri (Issue 2, consens cross-model):
|
Arhitectura 2-treceri:
|
||||||
Trecerea 1 — read_only=True: dim-check (FileTooLarge) + detectie multi-sheet.
|
Trecerea 1 — read_only=True: dim-check (FileTooLarge) + detectie multi-sheet.
|
||||||
Trecerea 2 — normal-mode: header + merged cells + body.
|
Trecerea 2 — normal-mode: header + merged cells + body.
|
||||||
Aceasta separare e necesara deoarece openpyxl read_only=True nu vede celule imbinate.
|
Aceasta separare e necesara deoarece openpyxl read_only=True nu vede celule imbinate.
|
||||||
@@ -29,7 +29,7 @@ from typing import Any, NamedTuple
|
|||||||
MAX_ROWS = 5_000
|
MAX_ROWS = 5_000
|
||||||
MAX_BYTES = 5 * 1024 * 1024 # 5 MB
|
MAX_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||||
|
|
||||||
# Prag rata None pe o coloana obligatorie -> mesaj formule necalculate (Issue 3)
|
# Prag rata None pe o coloana obligatorie -> mesaj formule necalculate
|
||||||
FORMULA_NONE_RATE = 0.6
|
FORMULA_NONE_RATE = 0.6
|
||||||
|
|
||||||
# Coloane cheie pentru detectia footer-ului (trim structural)
|
# Coloane cheie pentru detectia footer-ului (trim structural)
|
||||||
@@ -82,7 +82,7 @@ class ParsedFile(NamedTuple):
|
|||||||
columns: list[str] # Numele coloanelor detectate (din header)
|
columns: list[str] # Numele coloanelor detectate (din header)
|
||||||
rows: list[dict[str, Any]] # Fiecare rand: {coloana: valoare_bruta}
|
rows: list[dict[str, Any]] # Fiecare rand: {coloana: valoare_bruta}
|
||||||
coercion_flags: dict[int, list[str]] # {row_index: [motive needs_review]}
|
coercion_flags: dict[int, list[str]] # {row_index: [motive needs_review]}
|
||||||
formula_columns: list[str] # Coloane cu rata None ridicata (Issue 3)
|
formula_columns: list[str] # Coloane cu rata None ridicata
|
||||||
date_col_format: dict[str, str] # {coloana: "DD.MM.YYYY" | "YYYY-MM-DD" | "native" | "ambiguous"}
|
date_col_format: dict[str, str] # {coloana: "DD.MM.YYYY" | "YYYY-MM-DD" | "native" | "ambiguous"}
|
||||||
|
|
||||||
|
|
||||||
@@ -230,13 +230,13 @@ def _xlsx_parse_sheet(ws, sheet_name: str) -> ParsedFile:
|
|||||||
# Trim footer: elimina randuri trailing unde coloanele cheie sunt goale
|
# Trim footer: elimina randuri trailing unde coloanele cheie sunt goale
|
||||||
raw_rows = _trim_footer(raw_rows, col_names)
|
raw_rows = _trim_footer(raw_rows, col_names)
|
||||||
|
|
||||||
# Detectie coloane cu formule (rata None, Issue 3)
|
# Detectie coloane cu formule (rata None ridicata)
|
||||||
formula_columns = _detect_formula_columns(col_values, len(raw_rows))
|
formula_columns = _detect_formula_columns(col_values, len(raw_rows))
|
||||||
|
|
||||||
# Detectie format data la nivel de coloana (T10/OV-8)
|
# Detectie format data la nivel de coloana
|
||||||
date_col_format = _detect_date_formats(col_values, col_names)
|
date_col_format = _detect_date_formats(col_values, col_names)
|
||||||
|
|
||||||
# Coercion + flags needs_review (T3)
|
# Coercion + flags needs_review
|
||||||
coercion_flags: dict[int, list[str]] = {}
|
coercion_flags: dict[int, list[str]] = {}
|
||||||
processed_rows: list[dict[str, Any]] = []
|
processed_rows: list[dict[str, Any]] = []
|
||||||
for i, row_dict in enumerate(raw_rows):
|
for i, row_dict in enumerate(raw_rows):
|
||||||
@@ -289,7 +289,7 @@ def _trim_footer(rows: list[dict[str, Any]], col_names: list[str]) -> list[dict[
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Detectie coloane formule (Issue 3) #
|
# Detectie coloane formule #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
def _detect_formula_columns(col_values: dict[str, list[Any]], n_rows: int) -> list[str]:
|
def _detect_formula_columns(col_values: dict[str, list[Any]], n_rows: int) -> list[str]:
|
||||||
@@ -306,7 +306,7 @@ def _detect_formula_columns(col_values: dict[str, list[Any]], n_rows: int) -> li
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Dezambiguizare data la nivel de coloana (T10 / OV-8) #
|
# Dezambiguizare data la nivel de coloana #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
def _detect_date_formats(col_values: dict[str, list[Any]], col_names: list[str]) -> dict[str, str]:
|
def _detect_date_formats(col_values: dict[str, list[Any]], col_names: list[str]) -> dict[str, str]:
|
||||||
@@ -344,7 +344,7 @@ def _detect_date_formats(col_values: dict[str, list[Any]], col_names: list[str])
|
|||||||
result[col_name] = "mixed"
|
result[col_name] = "mixed"
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Toate string — detectie format la nivel de coloana (OV-8)
|
# Toate string — detectie format la nivel de coloana
|
||||||
fmt = _infer_date_format_from_column(str_vals)
|
fmt = _infer_date_format_from_column(str_vals)
|
||||||
result[col_name] = fmt
|
result[col_name] = fmt
|
||||||
|
|
||||||
@@ -354,7 +354,7 @@ def _detect_date_formats(col_values: dict[str, list[Any]], col_names: list[str])
|
|||||||
def _infer_date_format_from_column(str_vals: list[str]) -> str:
|
def _infer_date_format_from_column(str_vals: list[str]) -> str:
|
||||||
"""Detecteaza formatul datei dintr-o lista de valori string.
|
"""Detecteaza formatul datei dintr-o lista de valori string.
|
||||||
|
|
||||||
Logica OV-8: daca ORICARE rand are token pozitia-1 > 12 -> coloana e DD-first.
|
Daca ORICARE rand are token pozitia-1 > 12 -> coloana e DD-first.
|
||||||
Daca toti zi <= 12 -> ambiguu.
|
Daca toti zi <= 12 -> ambiguu.
|
||||||
"""
|
"""
|
||||||
dd_first_evidence = False
|
dd_first_evidence = False
|
||||||
@@ -421,7 +421,7 @@ def _split_date(s: str) -> list[str] | None:
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Coercion per rand (T3) #
|
# Coercion per rand #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
def _coerce_row(row_dict: dict[str, Any], col_names: list[str]) -> tuple[dict[str, Any], list[str]]:
|
def _coerce_row(row_dict: dict[str, Any], col_names: list[str]) -> tuple[dict[str, Any], list[str]]:
|
||||||
@@ -682,7 +682,7 @@ def parse_csv(data: bytes) -> ParsedFile:
|
|||||||
def parse_xlsx(data: bytes, *, sheet_name: str | None = None) -> ParsedFile:
|
def parse_xlsx(data: bytes, *, sheet_name: str | None = None) -> ParsedFile:
|
||||||
"""Parseaza un fisier XLSX.
|
"""Parseaza un fisier XLSX.
|
||||||
|
|
||||||
Arhitectura 2-treceri (Issue 2):
|
Arhitectura 2-treceri:
|
||||||
1. read_only=True: dim-check + detectie multi-sheet
|
1. read_only=True: dim-check + detectie multi-sheet
|
||||||
2. normal-mode: header + merged cells + body
|
2. normal-mode: header + merged cells + body
|
||||||
|
|
||||||
|
|||||||
19
app/main.py
19
app/main.py
@@ -1,9 +1,7 @@
|
|||||||
"""Aplicatia FastAPI: API v1 + dashboard web + /healthz + /metrics.
|
"""Aplicatia FastAPI: API v1 + dashboard web + /healthz + /metrics.
|
||||||
|
|
||||||
Worker-ul ruleaza ca PROCES SEPARAT (python -m app.worker), NU ca task aici
|
Worker-ul ruleaza ca PROCES SEPARAT (python -m app.worker), NU ca task aici:
|
||||||
(plan.md sect. 4: un worker mort nu trebuie sa lase containerul "sanatos").
|
un worker mort nu trebuie sa lase containerul "sanatos".
|
||||||
|
|
||||||
Pornire dev: uvicorn app.main:app --reload
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -44,7 +42,7 @@ from .web.session import AdminRequired, LoginRequired
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
install_log_redaction()
|
install_log_redaction()
|
||||||
# Fail-fast: o cheie Fernet setata dar invalida opreste pornirea cu mesaj clar,
|
# Fail-fast: o cheie Fernet setata dar invalida opreste pornirea cu mesaj clar,
|
||||||
# in loc de 500 brut la primul POST /v1/prezentari (cazul reprodus din VFP).
|
# in loc de 500 brut la primul POST /v1/prezentari.
|
||||||
validate_creds_key()
|
validate_creds_key()
|
||||||
init_db()
|
init_db()
|
||||||
yield
|
yield
|
||||||
@@ -61,7 +59,7 @@ app.add_middleware(
|
|||||||
https_only=settings.session_https_only,
|
https_only=settings.session_https_only,
|
||||||
same_site="strict",
|
same_site="strict",
|
||||||
)
|
)
|
||||||
# US-002: request_id pe fiecare cerere. Adaugat dupa SessionMiddleware -> ruleaza
|
# request_id pe fiecare cerere. Adaugat dupa SessionMiddleware -> ruleaza
|
||||||
# OUTERMOST (add_middleware prepend), deci `X-Request-ID` se pune pe TOATE raspunsurile,
|
# OUTERMOST (add_middleware prepend), deci `X-Request-ID` se pune pe TOATE raspunsurile,
|
||||||
# inclusiv 401/404/422/500 produse mai in interior.
|
# inclusiv 401/404/422/500 produse mai in interior.
|
||||||
app.add_middleware(RequestIDMiddleware)
|
app.add_middleware(RequestIDMiddleware)
|
||||||
@@ -97,13 +95,11 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
|||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
"""Orice excepție neprinsa -> 500 STRUCTURAT (3 niveluri, PRD 5.4) in loc de 500 brut.
|
"""Orice excepție neprinsa -> 500 STRUCTURAT din catalog in loc de 500 brut.
|
||||||
|
|
||||||
Body = envelope-ul standard din catalog (6 chei: field/cod/problema/cauza/fix/message)
|
Body = envelope-ul standard din catalog (6 chei: field/cod/problema/cauza/fix/message)
|
||||||
+ `request_id` — fara traceback, fara mesaj de excepție brut, fara creds. Traceback-ul
|
+ `request_id` — fara traceback, fara mesaj de excepție brut, fara creds. Traceback-ul
|
||||||
complet + ruta + cont + request_id se scriu DOAR in jurnal (redactate prin scrub_text).
|
complet + ruta + cont + request_id se scriu DOAR in jurnal (redactate prin scrub_text).
|
||||||
Handlerele specifice (LoginRequired/AdminRequired/CSRF/RequestValidationError/HTTPException)
|
|
||||||
raman neatinse — acesta prinde doar ce nu are handler dedicat.
|
|
||||||
"""
|
"""
|
||||||
request_id = getattr(request.state, "request_id", None) or request_id_var.get()
|
request_id = getattr(request.state, "request_id", None) or request_id_var.get()
|
||||||
try:
|
try:
|
||||||
@@ -144,9 +140,8 @@ app.include_router(admin_router)
|
|||||||
def healthz() -> dict:
|
def healthz() -> dict:
|
||||||
"""Sanatate: worker viu + ultimul login RAR reusit + adancime coada.
|
"""Sanatate: worker viu + ultimul login RAR reusit + adancime coada.
|
||||||
|
|
||||||
Pica (200 cu ok=False / sau folosit de orchestrator) cand worker-ul e mort
|
Intoarce 200 mereu cu detalii; orchestratorul decide restartul pe campul
|
||||||
-> semnal de restart (plan.md sect. 8). Intoarce 200 mereu cu detalii;
|
`worker_alive`.
|
||||||
orchestratorul decide pe campul `worker_alive`.
|
|
||||||
"""
|
"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Mapare operatie ROAAUTO -> cod prestatie RAR + fuzzy lookup pentru editor.
|
"""Mapare operatie ROAAUTO -> cod prestatie RAR + fuzzy lookup pentru editor.
|
||||||
|
|
||||||
Contract (varianta hibrida, decis 2026-06-15): un item de prestatie poate veni
|
Contract (varianta hibrida): un item de prestatie poate veni
|
||||||
fie cu `cod_prestatie` (cod RAR direct, ca pana acum), fie cu `cod_op_service`
|
fie cu `cod_prestatie` (cod RAR direct), fie cu `cod_op_service`
|
||||||
(cod intern ROAAUTO) + `denumire`. La ingestie incercam sa rezolvam codul intern
|
(cod intern ROAAUTO) + `denumire`. La ingestie incercam sa rezolvam codul intern
|
||||||
prin `operations_mapping`; daca nu exista mapare -> submission `needs_mapping`
|
prin `operations_mapping`; daca nu exista mapare -> submission `needs_mapping`
|
||||||
(nu se trimite la RAR), iar operatia apare in editorul web unde userul o mapeaza
|
(nu se trimite la RAR), iar operatia apare in editorul web unde userul o mapeaza
|
||||||
@@ -87,7 +87,7 @@ def suggest_codes(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# Prefixul pus pe `cod_sursa` cand un item e rezolvat printr-o regula text (US-010).
|
# Prefixul pus pe `cod_sursa` cand un item e rezolvat printr-o regula text.
|
||||||
# Forma: "text_rule:<pattern original al regulii castigatoare>". Payload-harmless —
|
# Forma: "text_rule:<pattern original al regulii castigatoare>". Payload-harmless —
|
||||||
# RAR citeste doar `cod_prestatie`; `cod_sursa` ramane in payload_json fara efect.
|
# RAR citeste doar `cod_prestatie`; `cod_sursa` ramane in payload_json fara efect.
|
||||||
COD_SURSA_TEXT_RULE_PREFIX = "text_rule:"
|
COD_SURSA_TEXT_RULE_PREFIX = "text_rule:"
|
||||||
@@ -111,10 +111,10 @@ def _rezolva_din_reguli_text(
|
|||||||
`valid_codes` (cand `valid_codes` e setat), nu intoarcem un cod invalid ->
|
`valid_codes` (cand `valid_codes` e setat), nu intoarcem un cod invalid ->
|
||||||
(None, None, None) (operatia ramane nemapata), coerent cu garda din `resolve_prestatii`.
|
(None, None, None) (operatia ramane nemapata), coerent cu garda din `resolve_prestatii`.
|
||||||
|
|
||||||
Pattern-ul intors e cel ORIGINAL al regulii (pentru telemetrie US-010), nu cel
|
Pattern-ul intors e cel ORIGINAL al regulii (pentru telemetrie), nu cel
|
||||||
normalizat folosit la match. `auto_send` = flagul regulii castigatoare: cand e
|
normalizat folosit la match. `auto_send` = flagul regulii castigatoare: cand e
|
||||||
falsy (DEFAULT 0, decizia CEO de siguranta) randul trebuie TINUT pentru verificare
|
falsy (DEFAULT 0, de siguranta) randul trebuie TINUT pentru verificare umana, nu
|
||||||
umana, nu trimis automat la RAR (blast radius substring + FINALIZATA ireversibil).
|
trimis automat la RAR (blast radius substring + FINALIZATA ireversibil).
|
||||||
"""
|
"""
|
||||||
if not text_rules:
|
if not text_rules:
|
||||||
return None, None, None
|
return None, None, None
|
||||||
@@ -136,7 +136,7 @@ def _rezolva_din_reguli_text(
|
|||||||
|
|
||||||
|
|
||||||
def text_rule_hits(resolved: list[dict] | None) -> list[dict]:
|
def text_rule_hits(resolved: list[dict] | None) -> list[dict]:
|
||||||
"""Extrage din itemii rezolvati cei care au primit cod dintr-o regula text (US-010).
|
"""Extrage din itemii rezolvati cei care au primit cod dintr-o regula text.
|
||||||
|
|
||||||
Intoarce [{pattern, cod_prestatie}] pentru fiecare item al carui `cod_sursa`
|
Intoarce [{pattern, cod_prestatie}] pentru fiecare item al carui `cod_sursa`
|
||||||
incepe cu `COD_SURSA_TEXT_RULE_PREFIX`. Pur (fara DB); apelantii cu `conn` il
|
incepe cu `COD_SURSA_TEXT_RULE_PREFIX`. Pur (fara DB); apelantii cu `conn` il
|
||||||
@@ -154,7 +154,7 @@ def text_rule_hits(resolved: list[dict] | None) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def text_rules_overlap(pattern: str, existing_rules: list[dict] | None) -> list[dict]:
|
def text_rules_overlap(pattern: str, existing_rules: list[dict] | None) -> list[dict]:
|
||||||
"""Reguli text existente care se SUPRAPUN cu `pattern` (US-011, avertisment neblocant).
|
"""Reguli text existente care se SUPRAPUN cu `pattern` (avertisment neblocant).
|
||||||
|
|
||||||
Overlap = pattern-ul nou normalizat (`normalize_for_match`) e substring al unei
|
Overlap = pattern-ul nou normalizat (`normalize_for_match`) e substring al unei
|
||||||
reguli existente SAU invers (oricare directie). Pur, determinist, fara DB.
|
reguli existente SAU invers (oricare directie). Pur, determinist, fara DB.
|
||||||
@@ -192,9 +192,9 @@ def resolve_prestatii(
|
|||||||
- item fara cod, fara mapare si fara regula text -> ramane nemapat.
|
- item fara cod, fara mapare si fara regula text -> ramane nemapat.
|
||||||
- item cu `cod_prestatie` NECUNOSCUT in nomenclator -> tratat ca operatie de
|
- item cu `cod_prestatie` NECUNOSCUT in nomenclator -> tratat ca operatie de
|
||||||
mapat: il promovam la `cod_op_service` (daca nu exista deja) ca sa intre in
|
mapat: il promovam la `cod_op_service` (daca nu exista deja) ca sa intre in
|
||||||
fluxul needs_mapping. Confirmat live (2026-06-23): RAR accepta NUMAI coduri
|
fluxul needs_mapping. RAR accepta NUMAI coduri din nomenclator (coloana
|
||||||
din nomenclator (coloana COD_PRESTATIE max 5 car.); un cod necunoscut da
|
COD_PRESTATIE max 5 car.); un cod necunoscut da HTTP 500 si RECORD PARTIAL
|
||||||
HTTP 500 si RECORD PARTIAL la RAR (terminal) -> nu-l trimitem niciodata raw.
|
la RAR (terminal) -> nu-l trimitem niciodata raw.
|
||||||
|
|
||||||
Precedenta (stricta): `cod_prestatie` direct valid > mapare exacta `cod_op_service`
|
Precedenta (stricta): `cod_prestatie` direct valid > mapare exacta `cod_op_service`
|
||||||
in `mapping` > reguli text > nemapat. Regulile text se incearca DOAR cand nu exista
|
in `mapping` > reguli text > nemapat. Regulile text se incearca DOAR cand nu exista
|
||||||
@@ -217,8 +217,8 @@ def resolve_prestatii(
|
|||||||
unmapped: list[dict] = []
|
unmapped: list[dict] = []
|
||||||
for item in prestatii or []:
|
for item in prestatii or []:
|
||||||
it = dict(item)
|
it = dict(item)
|
||||||
# Curata adnotarile aditive ale rezolvarii (cod_sursa US-010 + flagul de
|
# Curata adnotarile aditive ale rezolvarii (cod_sursa + flagul de hold pe
|
||||||
# hold pe regula auto_send=0): se recalculeaza de la zero la fiecare rezolvare.
|
# regula auto_send=0): se recalculeaza de la zero la fiecare rezolvare.
|
||||||
# Altfel, un item re-rezolvat acum prin alta cale (ex. mapare exacta) ar pastra
|
# Altfel, un item re-rezolvat acum prin alta cale (ex. mapare exacta) ar pastra
|
||||||
# un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit.
|
# un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit.
|
||||||
it.pop("cod_sursa", None)
|
it.pop("cod_sursa", None)
|
||||||
@@ -246,11 +246,11 @@ def resolve_prestatii(
|
|||||||
)
|
)
|
||||||
if cod_regula is not None:
|
if cod_regula is not None:
|
||||||
it["cod_prestatie"] = cod_regula
|
it["cod_prestatie"] = cod_regula
|
||||||
# Adnotare aditiva (US-010): marcheaza ca rezolvat-prin-regula cu
|
# Adnotare aditiva: marcheaza ca rezolvat-prin-regula cu pattern-ul
|
||||||
# pattern-ul sursa. Payload-harmless (RAR citeste doar cod_prestatie).
|
# sursa. Payload-harmless (RAR citeste doar cod_prestatie).
|
||||||
it["cod_sursa"] = f"{COD_SURSA_TEXT_RULE_PREFIX}{pattern_regula or ''}"
|
it["cod_sursa"] = f"{COD_SURSA_TEXT_RULE_PREFIX}{pattern_regula or ''}"
|
||||||
# Siguranta CEO (US-001): regula cu auto_send=0 rezolva codul dar
|
# Siguranta: regula cu auto_send=0 rezolva codul dar TINE randul
|
||||||
# TINE randul pentru verificare umana (has_no_auto_send -> True).
|
# pentru verificare umana (has_no_auto_send -> True).
|
||||||
if not auto_send_regula:
|
if not auto_send_regula:
|
||||||
it["regula_fara_autosend"] = True
|
it["regula_fara_autosend"] = True
|
||||||
else:
|
else:
|
||||||
@@ -273,7 +273,7 @@ def account_or_default(account_id: int | None) -> int:
|
|||||||
def account_scope_clause(account_id: int) -> tuple[str, list]:
|
def account_scope_clause(account_id: int) -> tuple[str, list]:
|
||||||
"""Fragment SQL + params pentru filtrarea pe cont in tabele cu account_id nullable.
|
"""Fragment SQL + params pentru filtrarea pe cont in tabele cu account_id nullable.
|
||||||
|
|
||||||
Aplica regula: NULL apartine contului 1 (legacy/OV-2).
|
Aplica regula: NULL apartine contului 1 (legacy).
|
||||||
Foloseste DOAR pe submissions (account_id NULLABLE).
|
Foloseste DOAR pe submissions (account_id NULLABLE).
|
||||||
NU folosi pe operations_mapping (account_id NOT NULL) — acolo WHERE account_id=? simplu.
|
NU folosi pe operations_mapping (account_id NOT NULL) — acolo WHERE account_id=? simplu.
|
||||||
"""
|
"""
|
||||||
@@ -356,7 +356,7 @@ def load_mapping(conn, account_id: int | None) -> dict[str, str]:
|
|||||||
def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]:
|
def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]:
|
||||||
"""{cod_op_service -> {cod_prestatie, auto_send}} pentru un cont.
|
"""{cod_op_service -> {cod_prestatie, auto_send}} pentru un cont.
|
||||||
|
|
||||||
T6/OV-1: varianta extinsa care include si flagul auto_send per operatie.
|
Varianta extinsa care include si flagul auto_send per operatie.
|
||||||
"""
|
"""
|
||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -379,7 +379,7 @@ def classify_prezentare(
|
|||||||
"""Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte.
|
"""Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte.
|
||||||
|
|
||||||
Apelat de AMBELE rute (POST /v1/prezentari si POST /v1/prezentari/valideaza) pentru
|
Apelat de AMBELE rute (POST /v1/prezentari si POST /v1/prezentari/valideaza) pentru
|
||||||
a garanta acelasi verdict — invariantul de corectitudine dry-run (PRD 5.2).
|
a garanta acelasi verdict — invariantul de corectitudine dry-run.
|
||||||
|
|
||||||
Intoarce {"status", "rar_error", "resolved", "unmapped", "errors", "content"}.
|
Intoarce {"status", "rar_error", "resolved", "unmapped", "errors", "content"}.
|
||||||
"content" = copia actualizata (VIN/nr canonicalizat + prestatii rezolvate).
|
"content" = copia actualizata (VIN/nr canonicalizat + prestatii rezolvate).
|
||||||
@@ -434,10 +434,10 @@ def classify_prezentare(
|
|||||||
def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> bool:
|
def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> bool:
|
||||||
"""Verifica daca vreun item rezolvat are auto_send=0 (mapare exacta SAU regula text).
|
"""Verifica daca vreun item rezolvat are auto_send=0 (mapare exacta SAU regula text).
|
||||||
|
|
||||||
T6/OV-1: un cod nou-mapat (operations_mapping) cu auto_send=0 nu trebuie trimis automat.
|
Un cod nou-mapat (operations_mapping) cu auto_send=0 nu trebuie trimis automat.
|
||||||
PRD 5.8 US-001 (decizia CEO): la fel pentru un item rezolvat printr-o REGULA TEXT cu
|
La fel pentru un item rezolvat printr-o REGULA TEXT cu auto_send=0 — marcat de
|
||||||
auto_send=0 — marcat de `resolve_prestatii` cu `regula_fara_autosend`. In ambele cazuri
|
`resolve_prestatii` cu `regula_fara_autosend`. In ambele cazuri randul ramane
|
||||||
randul ramane needs_mapping (review manual) pana cand operatorul activeaza „In coada".
|
needs_mapping (review manual) pana cand operatorul activeaza „In coada".
|
||||||
Items cu cod_prestatie direct (nu via cod_op_service/regula) nu sunt afectate.
|
Items cu cod_prestatie direct (nu via cod_op_service/regula) nu sunt afectate.
|
||||||
"""
|
"""
|
||||||
for item in resolved:
|
for item in resolved:
|
||||||
@@ -457,7 +457,7 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
|
|||||||
footgun (scurge cross-account) si e rezervat exclusiv pentru dashboard-ul intern.
|
footgun (scurge cross-account) si e rezervat exclusiv pentru dashboard-ul intern.
|
||||||
|
|
||||||
account_id=int: filtreaza in SQL pe cont inclusiv randuri legacy (account_id IS NULL
|
account_id=int: filtreaza in SQL pe cont inclusiv randuri legacy (account_id IS NULL
|
||||||
apartine contului 1, OV-2). Filtrarea in SQL, nu post-hoc in Python.
|
apartine contului 1). Filtrarea in SQL, nu post-hoc in Python.
|
||||||
"""
|
"""
|
||||||
nomenclator = load_nomenclator(conn)
|
nomenclator = load_nomenclator(conn)
|
||||||
if account_id is not None:
|
if account_id is not None:
|
||||||
@@ -577,9 +577,9 @@ def delete_text_rule(conn, account_id: int | None, pattern: str) -> None:
|
|||||||
def _emite_text_rule_hits(conn, account_id: int, submission_id: int, resolved: list[dict] | None) -> None:
|
def _emite_text_rule_hits(conn, account_id: int, submission_id: int, resolved: list[dict] | None) -> None:
|
||||||
"""Emite `text_rule_hit` in app_events pentru fiecare item rezolvat prin regula text.
|
"""Emite `text_rule_hit` in app_events pentru fiecare item rezolvat prin regula text.
|
||||||
|
|
||||||
US-010: telemetrie „ce regula a rezolvat ce submission". Best-effort (log_event
|
Telemetrie „ce regula a rezolvat ce submission". Best-effort (log_event inghite
|
||||||
inghite exceptiile). Context = {submission_id, account_id, pattern, cod_prestatie} —
|
exceptiile). Context = {submission_id, account_id, pattern, cod_prestatie} — fara
|
||||||
fara PII (pattern + cod nu sunt PII). Import local: evita orice risc de ciclu la import.
|
PII (pattern + cod nu sunt PII). Import local: evita orice risc de ciclu la import.
|
||||||
"""
|
"""
|
||||||
hits = text_rule_hits(resolved)
|
hits = text_rule_hits(resolved)
|
||||||
if not hits:
|
if not hits:
|
||||||
@@ -604,25 +604,25 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
|
|||||||
"""Re-rezolva submission-urile `needs_mapping` ale unui cont dupa o noua mapare.
|
"""Re-rezolva submission-urile `needs_mapping` ale unui cont dupa o noua mapare.
|
||||||
|
|
||||||
Pentru fiecare: aplica maparea curenta; daca nu mai raman op-uri nemapate ->
|
Pentru fiecare: aplica maparea curenta; daca nu mai raman op-uri nemapate ->
|
||||||
ruleaza validarea de continut (T3) si trece pe `queued` (sau `needs_data` cu
|
ruleaza validarea de continut si trece pe `queued` (sau `needs_data` cu
|
||||||
motiv), resetand backoff-ul. Daca raman nemapate, ramane `needs_mapping` cu
|
motiv), resetand backoff-ul. Daca raman nemapate, ramane `needs_mapping` cu
|
||||||
motivul actualizat. Intoarce {requeued, still_blocked, needs_data, review_manual}.
|
motivul actualizat. Intoarce {requeued, still_blocked, needs_data, review_manual}.
|
||||||
|
|
||||||
T6/OV-1: auto_send=0 pe un cod nou-mapat -> nu trece pe 'queued' (ramane
|
auto_send=0 pe un cod nou-mapat -> nu trece pe 'queued' (ramane 'needs_mapping'
|
||||||
'needs_mapping' cu motiv "review manual"); previne FINALIZATA eronat permanent.
|
cu motiv "review manual"); previne FINALIZATA eronat permanent.
|
||||||
|
|
||||||
T7: batch_id != None -> scope la seria comitata (NU cross-batch).
|
batch_id != None -> scope la seria comitata (NU cross-batch).
|
||||||
batch_id is None -> re-rezolva toti (canal API, batch_id IS NULL inclus).
|
batch_id is None -> re-rezolva toti (canal API, batch_id IS NULL inclus).
|
||||||
"""
|
"""
|
||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
mapping_meta = load_mapping_meta(conn, acct)
|
mapping_meta = load_mapping_meta(conn, acct)
|
||||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||||
valid_codes = load_nomenclator_codes(conn) or None
|
valid_codes = load_nomenclator_codes(conn) or None
|
||||||
# T2: incarca regulile text O DATA, inainte de bucla pe randuri.
|
# Incarca regulile text O DATA, inainte de bucla pe randuri.
|
||||||
text_rules = load_text_rules(conn, acct)
|
text_rules = load_text_rules(conn, acct)
|
||||||
|
|
||||||
if batch_id is not None:
|
if batch_id is not None:
|
||||||
# T7: scope la batch-ul specificat (import commit explicit).
|
# Scope la batch-ul specificat (import commit explicit).
|
||||||
# NU atinge randuri din alte batches sau din feed API.
|
# NU atinge randuri din alte batches sau din feed API.
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, payload_json FROM submissions "
|
"SELECT id, payload_json FROM submissions "
|
||||||
@@ -631,8 +631,8 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
else:
|
||||||
# POST /v1/mapari (save manual): re-rezolva EXCLUSIV canalul API (batch_id IS NULL).
|
# POST /v1/mapari (save manual): re-rezolva EXCLUSIV canalul API (batch_id IS NULL).
|
||||||
# T7/R1 INCHIS: salvarea unei mapari NU re-queues randuri din batches de import
|
# Salvarea unei mapari NU re-queues randuri din batches de import (cross-batch /
|
||||||
# (cross-batch / cross-feed). Batches de import sunt re-rezolvate doar la commit explicit.
|
# cross-feed). Batches de import sunt re-rezolvate doar la commit explicit.
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, payload_json FROM submissions "
|
"SELECT id, payload_json FROM submissions "
|
||||||
"WHERE status='needs_mapping' AND account_id=? AND batch_id IS NULL",
|
"WHERE status='needs_mapping' AND account_id=? AND batch_id IS NULL",
|
||||||
@@ -649,7 +649,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
|
|||||||
content["prestatii"] = resolved
|
content["prestatii"] = resolved
|
||||||
payload_json = json.dumps(content, ensure_ascii=False)
|
payload_json = json.dumps(content, ensure_ascii=False)
|
||||||
|
|
||||||
# US-010: telemetrie pentru itemii rezolvati prin regula text.
|
# Telemetrie pentru itemii rezolvati prin regula text.
|
||||||
_emite_text_rule_hits(conn, acct, r["id"], resolved)
|
_emite_text_rule_hits(conn, acct, r["id"], resolved)
|
||||||
|
|
||||||
if unmapped:
|
if unmapped:
|
||||||
@@ -660,7 +660,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
|
|||||||
stats["still_blocked"] += 1
|
stats["still_blocked"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# T6/OV-1: verifica auto_send inainte de re-queuing
|
# Verifica auto_send inainte de re-queuing.
|
||||||
if has_no_auto_send(resolved, mapping_meta):
|
if has_no_auto_send(resolved, mapping_meta):
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE submissions SET payload_json=?, rar_error=?, updated_at=datetime('now') WHERE id=?",
|
"UPDATE submissions SET payload_json=?, rar_error=?, updated_at=datetime('now') WHERE id=?",
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Modele Pydantic pentru suprafata API.
|
"""Modele Pydantic pentru suprafata API.
|
||||||
|
|
||||||
ATENTIE: validarea completa (regex VIN ^[A-HJ-NPR-Z0-9]{17}$, nrInmatriculare,
|
Aici sunt doar formele de baza + normalizare strip/upper. Validarea completa de
|
||||||
dataPrestatie ∈ [2024-12-01, azi] TZ Bucuresti, R-ODO/I-ODO -> odometruInitial
|
continut (regex VIN, interval data, R-ODO/I-ODO -> odometruInitial, ordine
|
||||||
obligatoriu, odometruInitial <= odometruFinal, normalizare strip/upper) este
|
odometru) este in app.validation.
|
||||||
**T3** — aici sunt doar formele de baza. Vezi plan.md sect. 2 + roadmap T3.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -20,7 +19,7 @@ class RarCredentials(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class PrestatieItem(BaseModel):
|
class PrestatieItem(BaseModel):
|
||||||
"""O operatie de declarat. Contract hibrid (decis 2026-06-15):
|
"""O operatie de declarat. Contract hibrid:
|
||||||
|
|
||||||
ROAAUTO poate trimite FIE `cod_prestatie` (cod RAR direct, ex. OE-1), FIE
|
ROAAUTO poate trimite FIE `cod_prestatie` (cod RAR direct, ex. OE-1), FIE
|
||||||
`cod_op_service` (cod intern ROAAUTO) + `denumire` — pe care gateway-ul le
|
`cod_op_service` (cod intern ROAAUTO) + `denumire` — pe care gateway-ul le
|
||||||
@@ -55,7 +54,7 @@ class PrezentareIn(BaseModel):
|
|||||||
Pydantic doar NORMALIZEAZA aici (strip/upper pe vin/nrInm). Validarea de
|
Pydantic doar NORMALIZEAZA aici (strip/upper pe vin/nrInm). Validarea de
|
||||||
continut (regex VIN, interval data, R-ODO/I-ODO, odometru) e in
|
continut (regex VIN, interval data, R-ODO/I-ODO, odometru) e in
|
||||||
app.validation.validate_prezentare si NU resping cererea — marcheaza
|
app.validation.validate_prezentare si NU resping cererea — marcheaza
|
||||||
`needs_data` (plan.md sect. 3).
|
`needs_data`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
vin: str
|
vin: str
|
||||||
@@ -102,12 +101,12 @@ class SubmissionResult(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
id_prezentare: int | None = None
|
id_prezentare: int | None = None
|
||||||
deduped: bool = False # True daca idempotency a intors un submission existent
|
deduped: bool = False # True daca idempotency a intors un submission existent
|
||||||
# US-012 (decizie /autoplan #19): camp ADITIV. True cand un rand `error` cu aceeasi
|
# Camp aditiv. True cand un rand `error` cu aceeasi cheie de continut a fost
|
||||||
# cheie de continut a fost RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit.
|
# RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. `deduped` pastreaza
|
||||||
# `deduped` pastreaza semantica actuala (clientii vechi care testeaza `deduped` nu se sparg).
|
# semantica actuala (clientii vechi care testeaza `deduped` nu se sparg).
|
||||||
reactivated: bool = False
|
reactivated: bool = False
|
||||||
# Raspuns ONEST pentru randuri blocate (PRD 5.7): orice status != 'queued' isi
|
# Raspuns ONEST pentru randuri blocate: orice status != 'queued' isi expune
|
||||||
# expune motivul, ca integratorul sa nu trateze un needs_data/needs_mapping drept succes.
|
# motivul, ca integratorul sa nu trateze un needs_data/needs_mapping drept succes.
|
||||||
# erori = validare de continut (needs_data), 3 niveluri [{field, cod, problema, cauza, fix, message}].
|
# erori = validare de continut (needs_data), 3 niveluri [{field, cod, problema, cauza, fix, message}].
|
||||||
# Pe ramura on_unmapped_error='error' pastreaza COD_NEMAPAT (compat).
|
# Pe ramura on_unmapped_error='error' pastreaza COD_NEMAPAT (compat).
|
||||||
# nemapate = coduri fara mapare RAR (needs_mapping / respins), 3 niveluri + cod_op_service/denumire.
|
# nemapate = coduri fara mapare RAR (needs_mapping / respins), 3 niveluri + cod_op_service/denumire.
|
||||||
@@ -122,7 +121,7 @@ class PrezentariResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ValidarePrezentariRequest(BaseModel):
|
class ValidarePrezentariRequest(BaseModel):
|
||||||
"""Body pentru POST /v1/prezentari/valideaza — dry-run fara enqueue (PRD 5.2)."""
|
"""Body pentru POST /v1/prezentari/valideaza — dry-run fara enqueue."""
|
||||||
|
|
||||||
rar_credentials: RarCredentials | None = None
|
rar_credentials: RarCredentials | None = None
|
||||||
prezentari: list[PrezentareIn] = Field(..., min_length=1)
|
prezentari: list[PrezentareIn] = Field(..., min_length=1)
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
"""Logger structurat central (PRD 5.6 US-003).
|
"""Logger structurat central.
|
||||||
|
|
||||||
Singurul punct prin care se emit evenimente de aplicatie: garanteaza format,
|
Singurul punct prin care se emit evenimente de aplicatie: garanteaza format,
|
||||||
redactare si dublul canal (app_events in DB + log text rotativ) consistente si
|
redactare si dublul canal (app_events in DB + log text rotativ) consistente si
|
||||||
imposibil de ocolit. Best-effort ca `notify_signup`: o cadere a jurnalului NU
|
imposibil de ocolit. Best-effort: o cadere a jurnalului NU doboara cererea/worker-ul.
|
||||||
doboara cererea/worker-ul.
|
|
||||||
|
|
||||||
Redactare la SCRIERE (nu la afisare): toate valorile trec prin `redact_pii`
|
Redactare la SCRIERE (nu la afisare): toate valorile trec prin `redact_pii`
|
||||||
(creds/token mascate integral, VIN/nr partial) inainte de persistare (US-007).
|
(creds/token mascate integral, VIN/nr partial) inainte de persistare.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,8 +21,8 @@ from .config import get_settings
|
|||||||
from .db import get_connection, insert_app_event
|
from .db import get_connection, insert_app_event
|
||||||
from .security import redact_pii, scrub_text
|
from .security import redact_pii, scrub_text
|
||||||
|
|
||||||
# request_id al cererii curente (US-002). Setat de middleware-ul HTTP; disponibil
|
# request_id al cererii curente. Setat de middleware-ul HTTP; disponibil in
|
||||||
# in handlerul de erori (US-001) si aici, fara a polua semnaturile de functii.
|
# handlerul de erori si aici, fara a polua semnaturile de functii.
|
||||||
request_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
request_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
||||||
"request_id", default=None
|
"request_id", default=None
|
||||||
)
|
)
|
||||||
@@ -31,7 +30,7 @@ request_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|||||||
_LEVELS = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "WARN": 30, "ERROR": 40, "CRITICAL": 50}
|
_LEVELS = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "WARN": 30, "ERROR": 40, "CRITICAL": 50}
|
||||||
|
|
||||||
# Sursa implicita a evenimentelor pentru procesul curent. API = 'api' (default);
|
# Sursa implicita a evenimentelor pentru procesul curent. API = 'api' (default);
|
||||||
# worker-ul cheama set_source('worker') la pornire (T5: fisier per-proces).
|
# worker-ul cheama set_source('worker') la pornire (fisier per-proces).
|
||||||
_DEFAULT_SOURCE = "api"
|
_DEFAULT_SOURCE = "api"
|
||||||
|
|
||||||
_loggers: dict[str, logging.Logger] = {}
|
_loggers: dict[str, logging.Logger] = {}
|
||||||
@@ -46,9 +45,9 @@ def set_source(sursa: str) -> None:
|
|||||||
def _text_logger(sursa: str) -> logging.Logger:
|
def _text_logger(sursa: str) -> logging.Logger:
|
||||||
"""Logger cu RotatingFileHandler pe fisier per-proces (app-<sursa>.log).
|
"""Logger cu RotatingFileHandler pe fisier per-proces (app-<sursa>.log).
|
||||||
|
|
||||||
Rotatia pe dimensiune e in aplicatie (decizie §5) — nu depindem de deploy.
|
Rotatia pe dimensiune e in aplicatie — nu depindem de deploy. Cheia de cache
|
||||||
Cheia de cache include calea: la schimbarea log_dir (teste) se creeaza un logger
|
include calea: la schimbarea log_dir (teste) se creeaza un logger nou, fara a
|
||||||
nou, fara a acumula handlere duplicate pe acelasi fisier.
|
acumula handlere duplicate pe acelasi fisier.
|
||||||
"""
|
"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
path = settings.log_dir / f"app-{sursa}.log"
|
path = settings.log_dir / f"app-{sursa}.log"
|
||||||
@@ -94,10 +93,10 @@ def log_event(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Emite un eveniment: un rand `app_events` + o linie in logul text (acelasi continut redactat).
|
"""Emite un eveniment: un rand `app_events` + o linie in logul text (acelasi continut redactat).
|
||||||
|
|
||||||
- `tip`: text liber documentat (lista extensibila, decizie §5).
|
- `tip`: text liber documentat (lista extensibila).
|
||||||
- `nivel`: DEBUG|INFO|WARNING|ERROR|CRITICAL. Sub `AUTOPASS_LOG_LEVEL` -> ignorat.
|
- `nivel`: DEBUG|INFO|WARNING|ERROR|CRITICAL. Sub `AUTOPASS_LOG_LEVEL` -> ignorat.
|
||||||
- `context`: metadate (submission_id, count, status...) — NU payload PII integral.
|
- `context`: metadate (submission_id, count, status...) — NU payload PII integral.
|
||||||
- `conn`: reutilizeaza conexiunea apelantului pe calea fierbinte (evita contentie WAL, T4);
|
- `conn`: reutilizeaza conexiunea apelantului pe calea fierbinte (evita contentie WAL);
|
||||||
None -> deschide/inchide o conexiune proprie.
|
None -> deschide/inchide o conexiune proprie.
|
||||||
Best-effort: orice exceptie e inghitita (jurnalul nu trebuie sa rupa fluxul).
|
Best-effort: orice exceptie e inghitita (jurnalul nu trebuie sa rupa fluxul).
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Extragere payload submission -> campuri afisabile (US-003, PRD 3.5).
|
"""Extragere payload submission -> campuri afisabile.
|
||||||
|
|
||||||
Helper PUR partajat intre canalul web (dashboard Trimiteri) si canalul API
|
Helper PUR partajat intre canalul web (dashboard Trimiteri) si canalul API
|
||||||
(`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua (decizie
|
(`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua (decizie
|
||||||
@@ -115,10 +115,10 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
|
|||||||
# cod_rar: exclusiv din cod_prestatie (NU fallback la cod_op_service); uppercase + strip ".0"
|
# cod_rar: exclusiv din cod_prestatie (NU fallback la cod_op_service); uppercase + strip ".0"
|
||||||
cod_rar = _clean_cod_rar(item.get("cod_prestatie"))
|
cod_rar = _clean_cod_rar(item.get("cod_prestatie"))
|
||||||
|
|
||||||
# US-002: operatia de service originala (codul intern + denumire venita prin API/import),
|
# Operatia de service originala (codul intern + denumire venita prin API/import),
|
||||||
# distincta de operatia RAR mapata (cod_rar).
|
# distincta de operatia RAR mapata (cod_rar).
|
||||||
# Conventie goala: aceste campuri NOI intorc "" (string gol) cand lipsesc — NU EMPTY="—".
|
# Conventie goala: aceste campuri intorc "" (string gol) cand lipsesc — NU EMPTY="—".
|
||||||
# Motivul: US-007 decide sa nu afiseze randul deloc (vs afisaj gol), testând `!= ""`.
|
# Motivul: apelantul decide sa nu afiseze randul deloc (vs afisaj gol), testând `!= ""`.
|
||||||
# Campurile vechi (vehicul_nr, vin, operatie etc.) pastreaza conventia EMPTY="—".
|
# Campurile vechi (vehicul_nr, vin, operatie etc.) pastreaza conventia EMPTY="—".
|
||||||
op_service_cod = _clean_str(item.get("cod_op_service"))
|
op_service_cod = _clean_str(item.get("cod_op_service"))
|
||||||
# op_service_denumire e relevant doar cand exista un cod de operatie de service;
|
# op_service_denumire e relevant doar cand exista un cod de operatie de service;
|
||||||
@@ -134,7 +134,7 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
|
|||||||
"odometru": odo or EMPTY,
|
"odometru": odo or EMPTY,
|
||||||
"cod": cod or EMPTY,
|
"cod": cod or EMPTY,
|
||||||
"cod_rar": cod_rar or EMPTY,
|
"cod_rar": cod_rar or EMPTY,
|
||||||
# US-002: chei noi cu conventie goala "" (nu EMPTY) — vezi comentariu de mai sus
|
# Chei cu conventie goala "" (nu EMPTY) — vezi comentariu de mai sus
|
||||||
"op_service_cod": op_service_cod,
|
"op_service_cod": op_service_cod,
|
||||||
"op_service_denumire": op_service_denumire,
|
"op_service_denumire": op_service_denumire,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ SENSITIVE_KEYS = frozenset(
|
|||||||
|
|
||||||
|
|
||||||
# Chei al caror continut e PII de identificare vehicul/proprietar: se logheaza DOAR
|
# Chei al caror continut e PII de identificare vehicul/proprietar: se logheaza DOAR
|
||||||
# partial (ultimele 4), niciodata integral (PRD 5.6 US-007, L.142/GDPR).
|
# partial (ultimele 4), niciodata integral (L.142/GDPR).
|
||||||
PII_PARTIAL_KEYS = frozenset({"vin", "nr_inmatriculare", "nr", "numar"})
|
PII_PARTIAL_KEYS = frozenset({"vin", "nr_inmatriculare", "nr", "numar"})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
"""Lifecycle trimiteri blocate: sterge / re-pune in coada (PRD 5.6 US-009).
|
"""Lifecycle trimiteri blocate: sterge / re-pune in coada.
|
||||||
|
|
||||||
Inchide lacuna descoperita live: un rand `error` (creds RAR gresite) ramane altfel
|
Inchide lacuna: un rand `error` (creds RAR gresite) ar ramane altfel permanent si
|
||||||
permanent si nereparabil. Aceste helpere adauga DOUA tranzitii controlate —
|
nereparabil. Aceste helpere adauga DOUA tranzitii controlate — stergere de randuri
|
||||||
stergere de randuri ne-sent si `blocate -> queued` (re-clasificat) — fara a atinge
|
ne-sent si `blocate -> queued` (re-clasificat) — fara a atinge logica de trimitere
|
||||||
logica de trimitere a worker-ului.
|
a worker-ului.
|
||||||
|
|
||||||
Invariante (decizii §2 + /autoplan #20):
|
Invariante:
|
||||||
- Opereaza DOAR pe `error`/`needs_data`/`needs_mapping`. `sent` (dovada de trimitere
|
- Opereaza DOAR pe `error`/`needs_data`/`needs_mapping`. `sent` (dovada de trimitere
|
||||||
la RAR, audit) si `sending` (lease worker in zbor) sunt INTERZISE.
|
la RAR, audit) si `sending` (lease worker in zbor) sunt INTERZISE.
|
||||||
- Scope-ul (apartenenta la cont) se evalueaza INAINTEA starii: un rand inexistent SAU
|
- Scope-ul (apartenenta la cont) se evalueaza INAINTEA starii: un rand inexistent SAU
|
||||||
al altui cont -> SubmissionNotFound (404, nu confirmam existenta, B3). Doar pe randuri
|
al altui cont -> SubmissionNotFound (404, nu confirmam existenta). Doar pe randuri
|
||||||
proprii in stare gresita -> SubmissionStateConflict (409).
|
proprii in stare gresita -> SubmissionStateConflict (409).
|
||||||
- Ambele emit eveniment in jurnal (US-003): `submission_sters` / `submission_repus`.
|
- Ambele emit eveniment in jurnal: `submission_sters` / `submission_repus`.
|
||||||
|
|
||||||
Functii cu `conn` (persistenta). Apelate din API (US-010) si din web (US-011).
|
Functii cu `conn` (persistenta).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -80,7 +80,7 @@ def requeue_submission(conn, account_id: int, sid: int) -> dict:
|
|||||||
|
|
||||||
`error -> queued` (cand continutul e valid) sau ramane `needs_data`/`needs_mapping`
|
`error -> queued` (cand continutul e valid) sau ramane `needs_data`/`needs_mapping`
|
||||||
daca clasificarea o cere. Reseteaza retry_count/next_attempt_at/sending_since si
|
daca clasificarea o cere. Reseteaza retry_count/next_attempt_at/sending_since si
|
||||||
CURATA `purge_after` (randul redevine activ, nu mai e candidat la purjare — US-013).
|
CURATA `purge_after` (randul redevine activ, nu mai e candidat la purjare).
|
||||||
Ridica SubmissionNotFound / SubmissionStateConflict. Intoarce
|
Ridica SubmissionNotFound / SubmissionStateConflict. Intoarce
|
||||||
{"submission_id", "status_anterior", "status_nou"}.
|
{"submission_id", "status_anterior", "status_nou"}.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Helper-e utilizatori web (email + parola scrypt). US-001 PRD 3.3.
|
"""Helper-e utilizatori web (email + parola scrypt).
|
||||||
|
|
||||||
Parola NICIODATA stocata in clar. Fiecare user are un salt per-user generat cu
|
Parola NICIODATA stocata in clar. Fiecare user are un salt per-user generat cu
|
||||||
secrets.token_bytes(16). Parametrii scrypt stocati ca eticheta de versiune pentru
|
secrets.token_bytes(16). Parametrii scrypt stocati ca eticheta de versiune pentru
|
||||||
migrare cost viitoare (C9).
|
migrare cost viitoare.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -98,7 +98,7 @@ def set_admin(conn: sqlite3.Connection, account_id: int, is_admin: bool = True)
|
|||||||
"""Seteaza/sterge rolul admin pe toti userii contului dat.
|
"""Seteaza/sterge rolul admin pe toti userii contului dat.
|
||||||
|
|
||||||
Ridica ValueError daca contul nu exista.
|
Ridica ValueError daca contul nu exista.
|
||||||
Daca contul exista dar nu are useri, e no-op silentios (confom spec US-010).
|
Daca contul exista dar nu are useri, e no-op silentios.
|
||||||
"""
|
"""
|
||||||
acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
|
acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||||
if not acct:
|
if not acct:
|
||||||
@@ -119,7 +119,7 @@ def is_account_admin(conn: sqlite3.Connection, account_id: int) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def list_admin_emails(conn: sqlite3.Connection) -> list[str]:
|
def list_admin_emails(conn: sqlite3.Connection) -> list[str]:
|
||||||
"""Returneaza emailurile tuturor userilor cu is_admin=1 (folosit de US-012)."""
|
"""Returneaza emailurile tuturor userilor cu is_admin=1."""
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT email FROM users WHERE is_admin=1"
|
"SELECT email FROM users WHERE is_admin=1"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Panou admin web /admin. US-011 PRD 3.3b.
|
"""Panou admin web /admin.
|
||||||
|
|
||||||
Rute:
|
Rute:
|
||||||
GET /admin — listeaza conturi in asteptare + active (require_admin)
|
GET /admin — listeaza conturi in asteptare + active (require_admin)
|
||||||
@@ -49,7 +49,7 @@ def _render_admin(request: Request, conn, *, error: str | None = None, status_co
|
|||||||
emails = _emails_by_account(conn)
|
emails = _emails_by_account(conn)
|
||||||
for acct in accounts:
|
for acct in accounts:
|
||||||
acct["email"] = emails.get(acct["id"])
|
acct["email"] = emails.get(acct["id"])
|
||||||
# Grupare pe STARE (5.5), nu pe `active`: altfel conturile arhivate/blocate (active=0)
|
# Grupare pe STARE, nu pe `active`: altfel conturile arhivate/blocate (active=0)
|
||||||
# ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts.
|
# ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts.
|
||||||
pending = [a for a in accounts if a["status"] == "pending" and a["id"] != 1]
|
pending = [a for a in accounts if a["status"] == "pending" and a["id"] != 1]
|
||||||
active = [a for a in accounts if a["status"] == "active" and a["id"] != 1]
|
active = [a for a in accounts if a["status"] == "active" and a["id"] != 1]
|
||||||
@@ -79,7 +79,7 @@ async def admin_get(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
def _apply_lifecycle(conn, ids: list[int], action: str) -> None:
|
def _apply_lifecycle(conn, ids: list[int], action: str) -> None:
|
||||||
"""Aplica un verb de ciclu de viata (5.5) pe o lista de conturi. Conturile protejate
|
"""Aplica un verb de ciclu de viata pe o lista de conturi. Conturile protejate
|
||||||
(id=1) sau inexistente ridica ValueError din helperi -> sarite (nu opresc bulk-ul).
|
(id=1) sau inexistente ridica ValueError din helperi -> sarite (nu opresc bulk-ul).
|
||||||
`action`: activate | block | archive | delete."""
|
`action`: activate | block | archive | delete."""
|
||||||
for aid in ids:
|
for aid in ids:
|
||||||
@@ -97,7 +97,7 @@ def _apply_lifecycle(conn, ids: list[int], action: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _lifecycle_route(request: Request, account_id: list[int], csrf_token: str, action: str):
|
def _lifecycle_route(request: Request, account_id: list[int], csrf_token: str, action: str):
|
||||||
"""Corp comun pentru rutele de ciclu de viata (5.5): auth + CSRF + aplica verbul (bulk) + PRG.
|
"""Corp comun pentru rutele de ciclu de viata: auth + CSRF + aplica verbul (bulk) + PRG.
|
||||||
Evita 4 handlere copy-paste care difera doar prin verb."""
|
Evita 4 handlere copy-paste care difera doar prin verb."""
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
verify_csrf(request, csrf_token)
|
verify_csrf(request, csrf_token)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Rute autentificare web: /signup (US-003), /login + /logout (US-004). PRD 3.3."""
|
"""Rute autentificare web: /signup, /login, /logout."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""CSRF token per-sesiune + validare. US-009 PRD 3.3.
|
"""CSRF token per-sesiune + validare.
|
||||||
|
|
||||||
Contract pentru rutele POST web:
|
Contract pentru rutele POST web:
|
||||||
- Formulare HTML includ: <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
- Formulare HTML includ: <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
"""
|
"""Traducere stari tehnice in text uman + clasa CSS.
|
||||||
labels.py — traducere stari tehnice in text uman + clasa CSS (US-001, PRD 3.4).
|
|
||||||
|
|
||||||
Functii pure: fara DB, fara request. Usor de testat unitar si de importat in template-uri.
|
Functii pure: fara DB, fara request. Usor de testat unitar si de importat in template-uri.
|
||||||
|
|
||||||
Sursa de adevar pentru texte: tabelul din PRD 3.4 §3 US-001.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -59,7 +56,7 @@ STARI_SUBMISSION: dict[str, Eticheta] = {
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Etichete scurte (pill) pentru coloana Stare din tabelul de trimiteri (US-006)
|
# Etichete scurte (pill) pentru coloana Stare din tabelul de trimiteri
|
||||||
# Dict propriu — NU element in tuple Eticheta (ar rupe template-urile care
|
# Dict propriu — NU element in tuple Eticheta (ar rupe template-urile care
|
||||||
# despacheteaza 3 elemente). eticheta_stare ramane neatinsa.
|
# despacheteaza 3 elemente). eticheta_stare ramane neatinsa.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -155,7 +152,7 @@ def eticheta_rar(stare: str) -> Eticheta:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Format data RAR (US-001, PRD 3.5)
|
# Format data RAR
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def format_data_rar(raw: object) -> str:
|
def format_data_rar(raw: object) -> str:
|
||||||
@@ -181,7 +178,7 @@ def format_data_rar(raw: object) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Motiv uman din rar_error (US-004, PRD 3.5)
|
# Motiv uman din rar_error
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def motiv_uman(status: str, rar_error: object) -> str:
|
def motiv_uman(status: str, rar_error: object) -> str:
|
||||||
@@ -231,7 +228,7 @@ def motiv_uman(status: str, rar_error: object) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# parse_erori — transforma rar_error in lista 3-niveluri (US-006, PRD 5.4)
|
# parse_erori — transforma rar_error in lista 3-niveluri
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def parse_erori(rar_error: object) -> list[dict]:
|
def parse_erori(rar_error: object) -> list[dict]:
|
||||||
@@ -275,7 +272,7 @@ def parse_erori(rar_error: object) -> list[dict]:
|
|||||||
"cauza": e.get("cauza") or e.get("message") or "",
|
"cauza": e.get("cauza") or e.get("message") or "",
|
||||||
"fix": e.get("fix") or "",
|
"fix": e.get("fix") or "",
|
||||||
"field": e.get("field"),
|
"field": e.get("field"),
|
||||||
# Cod BRUT de catalog (ex. RAR_EROARE_SERVER) — DOAR pentru modal (US-001/R1).
|
# Cod BRUT de catalog (ex. RAR_EROARE_SERVER) — DOAR pentru modal.
|
||||||
"cod": e.get("cod"),
|
"cod": e.get("cod"),
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
@@ -305,7 +302,7 @@ def parse_erori(rar_error: object) -> list[dict]:
|
|||||||
"cauza": data.get("cauza") or "",
|
"cauza": data.get("cauza") or "",
|
||||||
"fix": data.get("fix") or "",
|
"fix": data.get("fix") or "",
|
||||||
"field": data.get("field"),
|
"field": data.get("field"),
|
||||||
# Cod BRUT de catalog (ex. COD_NEMAPAT) — DOAR pentru modal (US-001/R1).
|
# Cod BRUT de catalog (ex. COD_NEMAPAT) — DOAR pentru modal.
|
||||||
"cod": data.get("cod"),
|
"cod": data.get("cod"),
|
||||||
}]
|
}]
|
||||||
# Dict vechi: unmapped
|
# Dict vechi: unmapped
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Middleware HTTP: request_id per cerere (PRD 5.6 US-002).
|
"""Middleware HTTP: request_id per cerere.
|
||||||
|
|
||||||
Fiecare raspuns primeste un header `X-Request-ID` (generat daca clientul nu trimite
|
Fiecare raspuns primeste un header `X-Request-ID` (generat daca clientul nu trimite
|
||||||
unul). Pe durata cererii, id-ul e disponibil prin `observ.request_id_var` (contextvar)
|
unul). Pe durata cererii, id-ul e disponibil prin `observ.request_id_var` (contextvar)
|
||||||
in handlerul de erori (US-001) si in `log_event` (US-003) — fara a polua semnaturile.
|
in handlerul de erori si in `log_event` — fara a polua semnaturile.
|
||||||
|
|
||||||
Format opac, fara PII: `secrets.token_hex(8)` (16 hex). Daca clientul trimite un
|
Format opac, fara PII: `secrets.token_hex(8)` (16 hex). Daca clientul trimite un
|
||||||
`X-Request-ID`, il pastram (corelare end-to-end), dar il scurtam defensiv (max 64).
|
`X-Request-ID`, il pastram (corelare end-to-end), dar il scurtam defensiv (max 64).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Rate-limit in-proces cu fereastra glisanta. US-009 PRD 3.3 C5.
|
"""Rate-limit in-proces cu fereastra glisanta.
|
||||||
|
|
||||||
Fara dependinta externa. Folosit de POST /signup (US-003) cu cheia = IP client.
|
Fara dependinta externa. Folosit de POST /signup cu cheia = IP client.
|
||||||
Configurabil prin AUTOPASS_signup_rate_max / AUTOPASS_signup_rate_window_s (config.py).
|
Configurabil prin AUTOPASS_signup_rate_max / AUTOPASS_signup_rate_window_s (config.py).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
"""Dashboard Jinja2 + HTMX (server-rendered, zero build).
|
"""Dashboard Jinja2 + HTMX (server-rendered, zero build).
|
||||||
|
|
||||||
Schelet cu stari explicite: empty (coada goala), banner alerta blocate,
|
Schelet cu stari explicite: empty (coada goala), banner alerta blocate,
|
||||||
worker viu/mort, ultimul login RAR. Editor mapari + browser nomenclator +
|
worker viu/mort, ultimul login RAR. Editor mapari + browser nomenclator.
|
||||||
export CSV + stare "RAR indisponibil" = de adaugat (plan.md sect. 4 + design-review).
|
|
||||||
|
|
||||||
U5 — Rute web pentru import fisier (upload → mapare coloane → preview → confirma → trimite).
|
Rute web pentru import fisier (upload → mapare coloane → preview → confirma → trimite).
|
||||||
Consuma endpointurile backend din import_router (helper-e interne) fara a le modifica.
|
Consuma helper-e interne din import_router fara a le modifica. Toate rutele /_import/*
|
||||||
Toate rutele /_import/* returneaza fragmente HTML targetate pe #import-section prin HTMX.
|
returneaza fragmente HTML targetate pe #import-section prin HTMX.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -83,12 +82,12 @@ from ..mapping import (
|
|||||||
text_rules_overlap,
|
text_rules_overlap,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Campuri canonice cu eticheta umana pentru dropdown mapare coloane (U5)
|
# Campuri canonice cu eticheta umana pentru dropdown mapare coloane
|
||||||
_CANONICAL_FIELDS = [(k, v[0]) for k, v in _CANONICAL_SYNONYMS.items()]
|
_CANONICAL_FIELDS = [(k, v[0]) for k, v in _CANONICAL_SYNONYMS.items()]
|
||||||
|
|
||||||
router = APIRouter(tags=["web"])
|
router = APIRouter(tags=["web"])
|
||||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
||||||
# Expune parse_erori in toate template-urile (US-006, PRD 5.4)
|
# Expune parse_erori in toate template-urile
|
||||||
templates.env.globals["parse_erori"] = parse_erori
|
templates.env.globals["parse_erori"] = parse_erori
|
||||||
|
|
||||||
_BLOCKED = ("error", "needs_data", "needs_mapping")
|
_BLOCKED = ("error", "needs_data", "needs_mapping")
|
||||||
@@ -98,7 +97,7 @@ def _ctx(request: Request, **extra) -> dict:
|
|||||||
"""Context de baza pentru template-uri cu formulare: include mereu csrf_token.
|
"""Context de baza pentru template-uri cu formulare: include mereu csrf_token.
|
||||||
|
|
||||||
Previne lock-out in prod (web_auth_required=True): orice re-randare de eroare
|
Previne lock-out in prod (web_auth_required=True): orice re-randare de eroare
|
||||||
trebuie sa includa csrf_token negol altfel urmatorul submit da 403 (task #8).
|
trebuie sa includa csrf_token negol altfel urmatorul submit da 403.
|
||||||
"""
|
"""
|
||||||
return {"request": request, "csrf_token": get_csrf_token(request), **extra}
|
return {"request": request, "csrf_token": get_csrf_token(request), **extra}
|
||||||
|
|
||||||
@@ -161,15 +160,14 @@ def _rar_state(hb, worker_alive: bool) -> str:
|
|||||||
return "indisponibil?" if age > 108000 else "ok"
|
return "indisponibil?" if age > 108000 else "ok"
|
||||||
|
|
||||||
|
|
||||||
# US-002: "import" nu mai e tab separat — importul traieste pe Acasa. ?tab=import
|
# "import" si "coada" nu mai sunt tab-uri separate — importul si Trimiterile sunt
|
||||||
# cade pe Acasa (tab invalid -> fallback "acasa" in dashboard()), fara 404.
|
# sectiuni pe Acasa. ?tab=import / ?tab=coada cad pe Acasa (fallback in dashboard()),
|
||||||
# US-003 (3.6): "coada" (Trimiteri) nu mai e tab — Trimiterile sunt sectiune pe Acasa.
|
# fara 404 si fara fragment orfan.
|
||||||
# ?tab=coada cade tot pe Acasa (fallback), fara 404, fara fragment orfan.
|
|
||||||
_TABS_VALIDE = {"acasa", "mapari", "cont", "nomenclator", "integrare", "jurnal"}
|
_TABS_VALIDE = {"acasa", "mapari", "cont", "nomenclator", "integrare", "jurnal"}
|
||||||
|
|
||||||
|
|
||||||
def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
|
def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
|
||||||
"""Calculeaza contextul pentru panoul Acasa (US-005).
|
"""Calculeaza contextul pentru panoul Acasa.
|
||||||
|
|
||||||
Scoped pe contul sesiunii — identic cu pattern-ul din restul rutelor (NULL->1).
|
Scoped pe contul sesiunii — identic cu pattern-ul din restul rutelor (NULL->1).
|
||||||
"""
|
"""
|
||||||
@@ -197,8 +195,8 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
are_cheie_folosita = row_key is not None
|
are_cheie_folosita = row_key is not None
|
||||||
|
|
||||||
# US-003 (3.6): contorul de atentie (blocate) se reflecta in heading-ul
|
# Contorul de atentie (blocate) se reflecta in heading-ul sectiunii
|
||||||
# sectiunii "Trimiterile tale" de pe Acasa, nu pe un tab disparut.
|
# "Trimiterile tale" de pe Acasa.
|
||||||
counts = _status_counts(conn, account_id)
|
counts = _status_counts(conn, account_id)
|
||||||
blocate_total = sum(counts.get(s, 0) for s in _BLOCKED)
|
blocate_total = sum(counts.get(s, 0) for s in _BLOCKED)
|
||||||
|
|
||||||
@@ -212,7 +210,7 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
|
|||||||
"pills_categorii": _pills_categorii(counts),
|
"pills_categorii": _pills_categorii(counts),
|
||||||
# Semnatura datelor: nudge-ul "Date noi" o compara la fiecare poll usor.
|
# Semnatura datelor: nudge-ul "Date noi" o compara la fiecare poll usor.
|
||||||
"versiune_trimiteri": _trimiteri_versiune(conn, account_id),
|
"versiune_trimiteri": _trimiteri_versiune(conn, account_id),
|
||||||
# US-002: Acasa include caseta de upload -> are nevoie de csrf_token
|
# Acasa include caseta de upload -> are nevoie de csrf_token
|
||||||
"csrf_token": get_csrf_token(request),
|
"csrf_token": get_csrf_token(request),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,8 +218,8 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
|
|||||||
def _render_panel_acasa(request: Request, conn=None, account_id: int = 1, status: str | None = None) -> str:
|
def _render_panel_acasa(request: Request, conn=None, account_id: int = 1, status: str | None = None) -> str:
|
||||||
"""Randeaza panoul Acasa ca string HTML.
|
"""Randeaza panoul Acasa ca string HTML.
|
||||||
|
|
||||||
`status` (US-014/T13): deep-link `?tab=acasa&status=error` pre-selecteaza filtrul de
|
`status`: deep-link `?tab=acasa&status=error` pre-selecteaza filtrul de stare in
|
||||||
stare in sectiunea Trimiteri, astfel ca lista se incarca direct filtrata (nu dead-end).
|
sectiunea Trimiteri, astfel ca lista se incarca direct filtrata (nu dead-end).
|
||||||
"""
|
"""
|
||||||
if conn is None:
|
if conn is None:
|
||||||
return templates.get_template("_acasa.html").render(
|
return templates.get_template("_acasa.html").render(
|
||||||
@@ -243,8 +241,8 @@ def _render_panel_import(request: Request) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _render_panel_coada(request: Request, conn=None, account_id: int = 1) -> str:
|
def _render_panel_coada(request: Request, conn=None, account_id: int = 1) -> str:
|
||||||
"""US-003 (3.6): "coada" nu mai e panou propriu — serveste continutul Acasa
|
""""coada" nu mai e panou propriu — serveste continutul Acasa (Trimiterile sunt
|
||||||
(Trimiterile sunt sectiune pe Acasa). Pastrat ca alias pentru deep-link/bookmark vechi."""
|
sectiune pe Acasa). Pastrat ca alias pentru deep-link/bookmark vechi."""
|
||||||
return _render_panel_acasa(request, conn, account_id)
|
return _render_panel_acasa(request, conn, account_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -334,10 +332,10 @@ def _jurnal_context(
|
|||||||
data_de: str | None = None, data_pana: str | None = None,
|
data_de: str | None = None, data_pana: str | None = None,
|
||||||
cont: str | None = None, page: int = 0,
|
cont: str | None = None, page: int = 0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Context pentru tab-ul Jurnal (US-006): evenimente paginate + filtre + scope.
|
"""Context pentru tab-ul Jurnal: evenimente paginate + filtre + scope.
|
||||||
|
|
||||||
Admin -> vede TOT, cu filtru optional pe cont. Non-admin -> DOAR evenimentele
|
Admin -> vede TOT, cu filtru optional pe cont. Non-admin -> DOAR evenimentele
|
||||||
contului sau (regula NULL->cont 1, ca restul UI-ului). Decizie §5.
|
contului sau (regula NULL->cont 1, ca restul UI-ului).
|
||||||
"""
|
"""
|
||||||
admin = is_account_admin(conn, account_id)
|
admin = is_account_admin(conn, account_id)
|
||||||
tip = (tip or "").strip() or None
|
tip = (tip or "").strip() or None
|
||||||
@@ -397,7 +395,7 @@ def _jurnal_context(
|
|||||||
|
|
||||||
|
|
||||||
def _render_panel_jurnal(request: Request, conn, account_id: int) -> str:
|
def _render_panel_jurnal(request: Request, conn, account_id: int) -> str:
|
||||||
"""Randeaza panoul Jurnal ca string HTML (US-006)."""
|
"""Randeaza panoul Jurnal ca string HTML."""
|
||||||
return templates.get_template("_jurnal.html").render(_jurnal_context(request, conn, account_id))
|
return templates.get_template("_jurnal.html").render(_jurnal_context(request, conn, account_id))
|
||||||
|
|
||||||
|
|
||||||
@@ -424,20 +422,20 @@ def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str, sta
|
|||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -> HTMLResponse:
|
def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -> HTMLResponse:
|
||||||
"""Dashboard principal cu tab-uri (US-003).
|
"""Dashboard principal cu tab-uri.
|
||||||
|
|
||||||
Parametrul ?tab= permite deep-link pe orice sectiune; panoul activ e randat
|
Parametrul ?tab= permite deep-link pe orice sectiune; panoul activ e randat
|
||||||
server-side la full load (fara palpaiere la refresh, degradare gratiosa fara JS).
|
server-side la full load (fara palpaiere la refresh, degradare gratiosa fara JS).
|
||||||
Tab invalid -> fallback la 'acasa'. `?status=` (US-014/T13) pre-filtreaza lista
|
Tab invalid -> fallback la 'acasa'. `?status=` pre-filtreaza lista Trimiteri de
|
||||||
Trimiteri de pe Acasa (deep-link din banner-ul "Necesita atentia ta").
|
pe Acasa (deep-link din banner-ul "Necesita atentia ta").
|
||||||
"""
|
"""
|
||||||
account_id = require_login(request)
|
account_id = require_login(request)
|
||||||
active_tab = tab if tab in _TABS_VALIDE else "acasa"
|
active_tab = tab if tab in _TABS_VALIDE else "acasa"
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
panel_html = _render_panel_for_tab(request, conn, account_id, active_tab, status=status)
|
panel_html = _render_panel_for_tab(request, conn, account_id, active_tab, status=status)
|
||||||
# Badge contoare pe tab-uri (US-011): needs_mapping -> Mapari. Blocatele
|
# Badge contoare pe tab-uri: needs_mapping -> Mapari. Blocatele se reflecta in
|
||||||
# (fost badge "coada") se reflecta acum in heading-ul sectiunii Trimiteri (US-003).
|
# heading-ul sectiunii Trimiteri.
|
||||||
counts = _status_counts(conn, account_id)
|
counts = _status_counts(conn, account_id)
|
||||||
badges = {
|
badges = {
|
||||||
"mapari": counts.get("needs_mapping", 0),
|
"mapari": counts.get("needs_mapping", 0),
|
||||||
@@ -460,7 +458,7 @@ def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -
|
|||||||
|
|
||||||
@router.get("/_fragments/acasa", response_class=HTMLResponse)
|
@router.get("/_fragments/acasa", response_class=HTMLResponse)
|
||||||
def fragment_acasa(request: Request) -> HTMLResponse:
|
def fragment_acasa(request: Request) -> HTMLResponse:
|
||||||
"""Fragment HTMX pentru tab-ul Acasa (US-003, US-005)."""
|
"""Fragment HTMX pentru tab-ul Acasa."""
|
||||||
account_id = require_login(request)
|
account_id = require_login(request)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
@@ -472,16 +470,16 @@ def fragment_acasa(request: Request) -> HTMLResponse:
|
|||||||
|
|
||||||
@router.get("/_fragments/import", response_class=HTMLResponse)
|
@router.get("/_fragments/import", response_class=HTMLResponse)
|
||||||
def fragment_import(request: Request) -> HTMLResponse:
|
def fragment_import(request: Request) -> HTMLResponse:
|
||||||
"""Fragment HTMX pentru tab-ul Import — include zona de upload (US-003)."""
|
"""Fragment HTMX pentru tab-ul Import — include zona de upload."""
|
||||||
require_login(request)
|
require_login(request)
|
||||||
return templates.TemplateResponse("_upload.html", _ctx(request))
|
return templates.TemplateResponse("_upload.html", _ctx(request))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/_fragments/coada", response_class=HTMLResponse)
|
@router.get("/_fragments/coada", response_class=HTMLResponse)
|
||||||
def fragment_coada(request: Request) -> HTMLResponse:
|
def fragment_coada(request: Request) -> HTMLResponse:
|
||||||
"""US-003 (3.6): "coada" nu mai are fragment propriu. Serveste continutul Acasa
|
""""coada" nu mai are fragment propriu. Serveste continutul Acasa (Trimiterile sunt
|
||||||
(Trimiterile sunt sectiune permanenta pe Acasa) — evita un fragment `_coada.html`
|
sectiune permanenta pe Acasa) — evita un fragment `_coada.html` orfan din
|
||||||
orfan din bookmark-uri/HTMX vechi. Nu da 404."""
|
bookmark-uri/HTMX vechi. Nu da 404."""
|
||||||
account_id = require_login(request)
|
account_id = require_login(request)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
@@ -528,7 +526,7 @@ def fragment_jurnal(
|
|||||||
cont: str | None = None,
|
cont: str | None = None,
|
||||||
page: int = 0,
|
page: int = 0,
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Tab Jurnal (US-006): evenimente app_events paginate + filtre, scoped pe cont.
|
"""Tab Jurnal: evenimente app_events paginate + filtre, scoped pe cont.
|
||||||
|
|
||||||
Admin vede tot (filtru optional pe cont); non-admin doar evenimentele proprii.
|
Admin vede tot (filtru optional pe cont); non-admin doar evenimentele proprii.
|
||||||
"""
|
"""
|
||||||
@@ -563,8 +561,8 @@ def fragment_banner(request: Request) -> HTMLResponse:
|
|||||||
def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]:
|
def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]:
|
||||||
"""Construieste lista [(eticheta, n), ...] pentru starile blocate cu n > 0.
|
"""Construieste lista [(eticheta, n), ...] pentru starile blocate cu n > 0.
|
||||||
|
|
||||||
Ordinea: needs_mapping, needs_data, error — aceeasi ca in PRD.
|
Ordinea: needs_mapping, needs_data, error. Returneaza lista goala daca nu
|
||||||
Returneaza lista goala daca nu exista nicio stare blocata.
|
exista nicio stare blocata.
|
||||||
"""
|
"""
|
||||||
rezultat = []
|
rezultat = []
|
||||||
for status in ("needs_mapping", "needs_data", "error"):
|
for status in ("needs_mapping", "needs_data", "error"):
|
||||||
@@ -575,13 +573,12 @@ def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]:
|
|||||||
|
|
||||||
|
|
||||||
def _pills_categorii(counts: dict[str, int]) -> list[dict]:
|
def _pills_categorii(counts: dict[str, int]) -> list[dict]:
|
||||||
"""Pill-uri pentru starile cu problema (US-003 PRD 5.10).
|
"""Pill-uri pentru starile cu problema.
|
||||||
|
|
||||||
Inlocuieste _blocate_actionabil (care incarca PII/VIN per rand).
|
Reutilizeaza contoarele deja calculate din _status_counts (fara PII/VIN per rand).
|
||||||
Reutilizeaza contoarele deja calculate din _status_counts.
|
Returneaza lista goala daca nu exista nicio stare blocata.
|
||||||
Returneza lista goala daca nu exista nicio stare blocata.
|
|
||||||
"""
|
"""
|
||||||
# DESIGN.md §Componente: Lipsa cod = --warn (chihlimbar), celelalte categorii = --err (rosu).
|
# Lipsa cod = --warn (chihlimbar), celelalte categorii = --err (rosu).
|
||||||
# Culoarea e CSS variable name (nu clasa), injectata direct in style tag al pill-ului,
|
# Culoarea e CSS variable name (nu clasa), injectata direct in style tag al pill-ului,
|
||||||
# pentru ca s-needs_mapping in base.html e tot --err (incorect pentru pill).
|
# pentru ca s-needs_mapping in base.html e tot --err (incorect pentru pill).
|
||||||
PILL_DEFS = [
|
PILL_DEFS = [
|
||||||
@@ -598,7 +595,7 @@ def _pills_categorii(counts: dict[str, int]) -> list[dict]:
|
|||||||
|
|
||||||
@router.get("/_fragments/status", response_class=HTMLResponse)
|
@router.get("/_fragments/status", response_class=HTMLResponse)
|
||||||
def fragment_status(request: Request) -> HTMLResponse:
|
def fragment_status(request: Request) -> HTMLResponse:
|
||||||
"""Bara de status persistenta cu etichete umane (US-002, PRD 3.4).
|
"""Bara de status persistenta cu etichete umane.
|
||||||
|
|
||||||
Scoped pe contul sesiunii. Expune starea worker, legatura RAR, ultima
|
Scoped pe contul sesiunii. Expune starea worker, legatura RAR, ultima
|
||||||
autentificare, contorii de coada si defalcarea blocatelor pe motiv.
|
autentificare, contorii de coada si defalcarea blocatelor pe motiv.
|
||||||
@@ -623,7 +620,7 @@ def fragment_status(request: Request) -> HTMLResponse:
|
|||||||
"request": request,
|
"request": request,
|
||||||
"worker_lbl": worker_lbl,
|
"worker_lbl": worker_lbl,
|
||||||
"rar_lbl": rar_lbl,
|
"rar_lbl": rar_lbl,
|
||||||
# Stari binare pentru bife accesibile (US-001 PRD 3.5): glifa + culoare
|
# Stari binare pentru bife accesibile: glifa + culoare
|
||||||
"worker_ok": worker_alive,
|
"worker_ok": worker_alive,
|
||||||
"rar_ok": rar_ok,
|
"rar_ok": rar_ok,
|
||||||
"eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
"eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
||||||
@@ -657,9 +654,8 @@ def _iso_date_prefix(value: object) -> str | None:
|
|||||||
|
|
||||||
Permite filtrarea dupa data_prestatie chiar daca valoarea contine ora/minut/secunda
|
Permite filtrarea dupa data_prestatie chiar daca valoarea contine ora/minut/secunda
|
||||||
(ex. '2026-06-20 14:35:07' sau '2026-06-20T14:35:07') — extrage portiunea de data
|
(ex. '2026-06-20 14:35:07' sau '2026-06-20T14:35:07') — extrage portiunea de data
|
||||||
fara a exclude timestamp-urile (bug-ul fix US-001: _is_iso_date cerea len==10).
|
fara a exclude timestamp-urile. Valori care nu incep cu o data ISO valida
|
||||||
Valori care nu incep cu o data ISO valida (ex. '05.12.2024') intorc None si
|
(ex. '05.12.2024') intorc None si sunt excluse din filtru.
|
||||||
sunt excluse din filtru — comportament actual pastrat.
|
|
||||||
"""
|
"""
|
||||||
s = str(value or "").strip()
|
s = str(value or "").strip()
|
||||||
if len(s) < 10:
|
if len(s) < 10:
|
||||||
@@ -673,16 +669,16 @@ def _iso_date_prefix(value: object) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
# Stari care semnaleaza o problema ce necesita atentia operatorului. Eticheta umana
|
# Stari care semnaleaza o problema ce necesita atentia operatorului. Eticheta umana
|
||||||
# scurta de pe rand (US-001, R1) e ne-goala DOAR pe acestea; pe queued/sending/sent e "".
|
# scurta de pe rand e ne-goala DOAR pe acestea; pe queued/sending/sent e "".
|
||||||
_STARI_CU_PROBLEMA = ("error", "needs_data", "needs_mapping")
|
_STARI_CU_PROBLEMA = ("error", "needs_data", "needs_mapping")
|
||||||
|
|
||||||
|
|
||||||
def _eticheta_problema(status: str, motiv: str) -> str:
|
def _eticheta_problema(status: str, motiv: str) -> str:
|
||||||
"""Eticheta umana scurta a problemei pentru randul de tabel (US-001, R1).
|
"""Eticheta umana scurta a problemei pentru randul de tabel.
|
||||||
|
|
||||||
Reutilizeaza `motiv` (motiv_uman, deja calculat in randul de view) si cade pe
|
Reutilizeaza `motiv` (motiv_uman, deja calculat in randul de view) si cade pe
|
||||||
`eticheta_scurta` cand motivul e gol — NU re-parseaza `rar_error` (R1: DRY, fara
|
`eticheta_scurta` cand motivul e gol — NU re-parseaza `rar_error` (DRY, fara al
|
||||||
al 3-lea decoder). Codul BRUT de catalog ramane doar pentru modal, nu pe rand.
|
3-lea decoder). Codul BRUT de catalog ramane doar pentru modal, nu pe rand.
|
||||||
|
|
||||||
Sir gol pe stari fara problema (queued/sending/sent); ne-gol pe error/needs_*.
|
Sir gol pe stari fara problema (queued/sending/sent); ne-gol pe error/needs_*.
|
||||||
Defensiv: motiv_uman nu arunca, iar starile cu problema au intotdeauna eticheta
|
Defensiv: motiv_uman nu arunca, iar starile cu problema au intotdeauna eticheta
|
||||||
@@ -694,13 +690,13 @@ def _eticheta_problema(status: str, motiv: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _submission_row_view(r) -> dict:
|
def _submission_row_view(r) -> dict:
|
||||||
"""Imbogateste un rand de submission cu campuri afisabile umane (US-003/US-004)."""
|
"""Imbogateste un rand de submission cu campuri afisabile umane."""
|
||||||
eticheta = eticheta_stare(r["status"])
|
eticheta = eticheta_stare(r["status"])
|
||||||
motiv = motiv_uman(r["status"], r["rar_error"])
|
motiv = motiv_uman(r["status"], r["rar_error"])
|
||||||
return {
|
return {
|
||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"status": r["status"],
|
"status": r["status"],
|
||||||
# PRD 5.8 US-007/US-006: pill = eticheta scurta; textul lung ramane ca tooltip (title=).
|
# pill = eticheta scurta; textul lung ramane ca tooltip (title=).
|
||||||
"stare_scurt": eticheta_scurta(r["status"]),
|
"stare_scurt": eticheta_scurta(r["status"]),
|
||||||
"stare_text": eticheta[0],
|
"stare_text": eticheta[0],
|
||||||
"stare_css": eticheta[2],
|
"stare_css": eticheta[2],
|
||||||
@@ -708,15 +704,15 @@ def _submission_row_view(r) -> dict:
|
|||||||
"id_prezentare": r["id_prezentare"],
|
"id_prezentare": r["id_prezentare"],
|
||||||
"updated_at": format_data_rar(r["updated_at"]),
|
"updated_at": format_data_rar(r["updated_at"]),
|
||||||
"motiv": motiv,
|
"motiv": motiv,
|
||||||
# US-001/R1: eticheta umana scurta a problemei sub pill (text, nu cod brut).
|
# eticheta umana scurta a problemei sub pill (text, nu cod brut).
|
||||||
"eticheta_problema": _eticheta_problema(r["status"], motiv),
|
"eticheta_problema": _eticheta_problema(r["status"], motiv),
|
||||||
# US-011: randurile blocate (error/needs_data/needs_mapping) sunt selectabile
|
# randurile blocate (error/needs_data/needs_mapping) sunt selectabile pentru
|
||||||
# pentru stergere bulk; sent/sending/queued raman read-only (fara checkbox).
|
# stergere bulk; sent/sending/queued raman read-only (fara checkbox).
|
||||||
"gestionabil": r["status"] in _GESTIONABILE_WEB,
|
"gestionabil": r["status"] in _GESTIONABILE_WEB,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
_PAGE_SIZE = 25 # Marime pagina fixa (US-004 PRD 5.10)
|
_PAGE_SIZE = 25 # Marime pagina fixa
|
||||||
|
|
||||||
|
|
||||||
@router.get("/_fragments/submissions", response_class=HTMLResponse)
|
@router.get("/_fragments/submissions", response_class=HTMLResponse)
|
||||||
@@ -728,9 +724,9 @@ def fragment_submissions(
|
|||||||
data_pana: str | None = None,
|
data_pana: str | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Tabel Trimiteri, scoped pe cont, cu filtre optionale si paginare (US-009, US-004).
|
"""Tabel Trimiteri, scoped pe cont, cu filtre optionale si paginare.
|
||||||
|
|
||||||
US-004 H1: totalul se calculeaza DIFERIT dupa tipul de filtru:
|
Totalul se calculeaza DIFERIT dupa tipul de filtru:
|
||||||
- FARA filtru Python (status-only / niciun filtru): SQL COUNT(*) + LIMIT/OFFSET
|
- FARA filtru Python (status-only / niciun filtru): SQL COUNT(*) + LIMIT/OFFSET
|
||||||
- CU filtru vehicul/data activ: fetch-all -> filtreaza Python -> total=len -> slice
|
- CU filtru vehicul/data activ: fetch-all -> filtreaza Python -> total=len -> slice
|
||||||
SQL COUNT/LIMIT pe calea cu filtru Python ar da total gresit (taie inainte de filtru).
|
SQL COUNT/LIMIT pe calea cu filtru Python ar da total gresit (taie inainte de filtru).
|
||||||
@@ -756,8 +752,8 @@ def fragment_submissions(
|
|||||||
where_sql = " AND ".join(where)
|
where_sql = " AND ".join(where)
|
||||||
|
|
||||||
if filtru_python:
|
if filtru_python:
|
||||||
# Calea B: fetch-all, filtreaza in Python, slice (US-004 H1)
|
# Calea B: fetch-all, filtreaza in Python, slice.
|
||||||
# FARA LIMIT — altfel paginile >8 ar disparea silentios (bug PRD H1)
|
# FARA LIMIT — altfel paginile >8 ar disparea silentios.
|
||||||
rows_db = conn.execute(
|
rows_db = conn.execute(
|
||||||
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
|
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
|
||||||
f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC",
|
f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC",
|
||||||
@@ -773,7 +769,7 @@ def fragment_submissions(
|
|||||||
if vehicul_q not in hay:
|
if vehicul_q not in hay:
|
||||||
continue
|
continue
|
||||||
if data_de or data_pana:
|
if data_de or data_pana:
|
||||||
# Extragem portiunea YYYY-MM-DD (US-001 fix).
|
# Extragem portiunea YYYY-MM-DD.
|
||||||
d_prefix = _iso_date_prefix(prez["data_prestatie"])
|
d_prefix = _iso_date_prefix(prez["data_prestatie"])
|
||||||
if d_prefix is None:
|
if d_prefix is None:
|
||||||
continue
|
continue
|
||||||
@@ -785,7 +781,7 @@ def fragment_submissions(
|
|||||||
|
|
||||||
total = len(view_all)
|
total = len(view_all)
|
||||||
pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1
|
pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1
|
||||||
page = max(1, min(page, pages)) # clamp H2
|
page = max(1, min(page, pages)) # clamp la nr. de pagini
|
||||||
offset = (page - 1) * _PAGE_SIZE
|
offset = (page - 1) * _PAGE_SIZE
|
||||||
view = view_all[offset:offset + _PAGE_SIZE]
|
view = view_all[offset:offset + _PAGE_SIZE]
|
||||||
|
|
||||||
@@ -796,7 +792,7 @@ def fragment_submissions(
|
|||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
|
|
||||||
pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1
|
pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1
|
||||||
page = max(1, min(page, pages)) # clamp H2
|
page = max(1, min(page, pages)) # clamp la nr. de pagini
|
||||||
offset = (page - 1) * _PAGE_SIZE
|
offset = (page - 1) * _PAGE_SIZE
|
||||||
|
|
||||||
rows_db = conn.execute(
|
rows_db = conn.execute(
|
||||||
@@ -815,13 +811,13 @@ def fragment_submissions(
|
|||||||
"rows": view,
|
"rows": view,
|
||||||
"filtru_activ": filtru_activ,
|
"filtru_activ": filtru_activ,
|
||||||
"csrf_token": get_csrf_token(request),
|
"csrf_token": get_csrf_token(request),
|
||||||
# Paginare (US-004)
|
# Paginare
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"pages": pages,
|
"pages": pages,
|
||||||
"page_start": page_start,
|
"page_start": page_start,
|
||||||
"page_end": page_end,
|
"page_end": page_end,
|
||||||
# Filtre curente pentru linkurile de paginare (pastreaza filtrele, H2)
|
# Filtre curente pentru linkurile de paginare (pastreaza filtrele)
|
||||||
"f_status": status or "",
|
"f_status": status or "",
|
||||||
"f_vehicul": vehicul_q or "",
|
"f_vehicul": vehicul_q or "",
|
||||||
"f_data_de": data_de or "",
|
"f_data_de": data_de or "",
|
||||||
@@ -835,17 +831,17 @@ def fragment_submissions(
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
# Stari ne-trimise blocate pe care le putem corecta inline (US-010).
|
# Stari ne-trimise blocate pe care le putem corecta inline.
|
||||||
_CORECTABILE = ("needs_data", "needs_mapping")
|
_CORECTABILE = ("needs_data", "needs_mapping")
|
||||||
# US-006b: stari cu select editabil cod_prestatie (superset al _CORECTABILE: error
|
# Stari cu select editabil cod_prestatie (superset al _CORECTABILE: error primeste
|
||||||
# primeste select in formularul /repune, nu in /corecteaza — fara schimbare de vehicle fields).
|
# select in formularul /repune, nu in /corecteaza — fara schimbare de vehicle fields).
|
||||||
_EDITABILE_OP = ("needs_data", "needs_mapping", "error")
|
_EDITABILE_OP = ("needs_data", "needs_mapping", "error")
|
||||||
# Stari gestionabile prin lifecycle web (US-011): sterge / re-pune in coada.
|
# Stari gestionabile prin lifecycle web: sterge / re-pune in coada.
|
||||||
_GESTIONABILE_WEB = ("error", "needs_data", "needs_mapping")
|
_GESTIONABILE_WEB = ("error", "needs_data", "needs_mapping")
|
||||||
|
|
||||||
|
|
||||||
def _render_submissions(request: Request, conn, account_id: int) -> HTMLResponse:
|
def _render_submissions(request: Request, conn, account_id: int) -> HTMLResponse:
|
||||||
"""Re-randeaza lista Trimiteri (fara filtre) — folosit dupa actiuni bulk (US-011)."""
|
"""Re-randeaza lista Trimiteri (fara filtre) — folosit dupa actiuni bulk."""
|
||||||
scope_sql, scope_params = account_scope_clause(account_id)
|
scope_sql, scope_params = account_scope_clause(account_id)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
|
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
|
||||||
@@ -865,7 +861,7 @@ def _render_submissions(request: Request, conn, account_id: int) -> HTMLResponse
|
|||||||
|
|
||||||
|
|
||||||
def _payload_form_values(payload_json) -> dict:
|
def _payload_form_values(payload_json) -> dict:
|
||||||
"""Valori brute pentru prefill-ul formularului de corectie (US-010)."""
|
"""Valori brute pentru prefill-ul formularului de corectie."""
|
||||||
try:
|
try:
|
||||||
data = json.loads(payload_json) if payload_json else {}
|
data = json.loads(payload_json) if payload_json else {}
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
@@ -882,7 +878,7 @@ def _payload_form_values(payload_json) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _nemapate_pentru_submission(row, nomenclator: list[dict]) -> list[dict]:
|
def _nemapate_pentru_submission(row, nomenclator: list[dict]) -> list[dict]:
|
||||||
"""Operatiile nemapate ale UNUI submission needs_mapping, cu sugestii fuzzy (PRD 5.7).
|
"""Operatiile nemapate ale UNUI submission needs_mapping, cu sugestii fuzzy.
|
||||||
|
|
||||||
Echivalentul `pending_unmapped` restrans la un singur rand: parseaza payload_json,
|
Echivalentul `pending_unmapped` restrans la un singur rand: parseaza payload_json,
|
||||||
aduna prestatiile fara cod_prestatie (cu cod_op_service) si ataseaza sugestii din
|
aduna prestatiile fara cod_prestatie (cu cod_op_service) si ataseaza sugestii din
|
||||||
@@ -921,12 +917,12 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
|
|||||||
"""Context pentru _trimitere_detaliu.html dintr-un rand de submission.
|
"""Context pentru _trimitere_detaliu.html dintr-un rand de submission.
|
||||||
|
|
||||||
`conn`+`account_id` (optional): cand sunt date si randul e needs_mapping, expune
|
`conn`+`account_id` (optional): cand sunt date si randul e needs_mapping, expune
|
||||||
`nemapate_inline` + `nomenclator` pentru maparea inline din panou (PRD 5.7).
|
`nemapate_inline` + `nomenclator` pentru maparea inline din panou.
|
||||||
"""
|
"""
|
||||||
eticheta = eticheta_stare(row["status"])
|
eticheta = eticheta_stare(row["status"])
|
||||||
nemapate_inline: list[dict] = []
|
nemapate_inline: list[dict] = []
|
||||||
nomenclator: list[dict] = []
|
nomenclator: list[dict] = []
|
||||||
# Variabila interna: nomenclatorul complet (incarcat pentru needs_mapping, refolosit pt US-006)
|
# Nomenclatorul complet, incarcat pentru needs_mapping si refolosit mai jos.
|
||||||
_nomenclator_complet: list[dict] = []
|
_nomenclator_complet: list[dict] = []
|
||||||
if conn is not None and row["status"] == "needs_mapping":
|
if conn is not None and row["status"] == "needs_mapping":
|
||||||
# Un singur SELECT pe nomenclator: il refolosim si pentru sugestii si pentru dropdown.
|
# Un singur SELECT pe nomenclator: il refolosim si pentru sugestii si pentru dropdown.
|
||||||
@@ -934,14 +930,14 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
|
|||||||
nemapate_inline = _nemapate_pentru_submission(row, _nomenclator_complet)
|
nemapate_inline = _nemapate_pentru_submission(row, _nomenclator_complet)
|
||||||
nomenclator = _nomenclator_complet if nemapate_inline else []
|
nomenclator = _nomenclator_complet if nemapate_inline else []
|
||||||
|
|
||||||
# US-006/US-006b: nomenclator pentru selectul cod_prestatie — needs_data/needs_mapping (in
|
# Nomenclator pentru selectul cod_prestatie — needs_data/needs_mapping (in formularul
|
||||||
# formularul /corecteaza) + error (in formularul /repune). Refoloseste _nomenclator_complet
|
# /corecteaza) + error (in formularul /repune). Refoloseste _nomenclator_complet daca
|
||||||
# daca e deja incarcat (needs_mapping), altfel incarca fresh.
|
# e deja incarcat (needs_mapping), altfel incarca fresh.
|
||||||
nomenclator_rar: list[dict] = []
|
nomenclator_rar: list[dict] = []
|
||||||
if conn is not None and row["status"] in _EDITABILE_OP:
|
if conn is not None and row["status"] in _EDITABILE_OP:
|
||||||
nomenclator_rar = _nomenclator_complet if _nomenclator_complet else load_nomenclator(conn)
|
nomenclator_rar = _nomenclator_complet if _nomenclator_complet else load_nomenclator(conn)
|
||||||
|
|
||||||
# US-006: cod_prestatie curent din prima prestatie (pentru pre-selectare in select)
|
# cod_prestatie curent din prima prestatie (pentru pre-selectare in select)
|
||||||
cod_prestatie_curent = ""
|
cod_prestatie_curent = ""
|
||||||
try:
|
try:
|
||||||
_pd = json.loads(row["payload_json"] or "{}")
|
_pd = json.loads(row["payload_json"] or "{}")
|
||||||
@@ -969,14 +965,14 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
|
|||||||
"created_at": format_data_rar(row["created_at"]),
|
"created_at": format_data_rar(row["created_at"]),
|
||||||
"updated_at": format_data_rar(row["updated_at"]),
|
"updated_at": format_data_rar(row["updated_at"]),
|
||||||
"next_attempt_at": format_data_rar(row["next_attempt_at"]),
|
"next_attempt_at": format_data_rar(row["next_attempt_at"]),
|
||||||
# randuri ne-trimise blocate sunt corectabile (US-010); sent/sending nu
|
# randuri ne-trimise blocate sunt corectabile; sent/sending nu
|
||||||
"editabil": row["status"] in _CORECTABILE,
|
"editabil": row["status"] in _CORECTABILE,
|
||||||
# US-011: error/needs_data/needs_mapping pot fi sterse / re-puse in coada
|
# error/needs_data/needs_mapping pot fi sterse / re-puse in coada
|
||||||
"gestionabil": row["status"] in _GESTIONABILE_WEB,
|
"gestionabil": row["status"] in _GESTIONABILE_WEB,
|
||||||
# PRD 5.7: mapare inline (operatii nemapate ale acestui rand + nomenclator)
|
# mapare inline (operatii nemapate ale acestui rand + nomenclator)
|
||||||
"nemapate_inline": nemapate_inline,
|
"nemapate_inline": nemapate_inline,
|
||||||
"nomenclator": nomenclator,
|
"nomenclator": nomenclator,
|
||||||
# US-006: select cod_prestatie pentru stari editabile
|
# select cod_prestatie pentru stari editabile
|
||||||
"nomenclator_rar": nomenclator_rar,
|
"nomenclator_rar": nomenclator_rar,
|
||||||
"cod_prestatie_curent": cod_prestatie_curent,
|
"cod_prestatie_curent": cod_prestatie_curent,
|
||||||
"corectie_msg": message,
|
"corectie_msg": message,
|
||||||
@@ -988,7 +984,7 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
|
|||||||
|
|
||||||
|
|
||||||
def _fetch_submission_scoped(conn, account_id: int, submission_id: int):
|
def _fetch_submission_scoped(conn, account_id: int, submission_id: int):
|
||||||
"""Randul scoped pe cont sau None (404 cross-account, nu confirmam existenta — B3)."""
|
"""Randul scoped pe cont sau None (404 cross-account, nu confirmam existenta)."""
|
||||||
scope_sql, scope_params = account_scope_clause(account_id)
|
scope_sql, scope_params = account_scope_clause(account_id)
|
||||||
return conn.execute(
|
return conn.execute(
|
||||||
f"SELECT * FROM submissions WHERE id=? AND {scope_sql}",
|
f"SELECT * FROM submissions WHERE id=? AND {scope_sql}",
|
||||||
@@ -996,14 +992,14 @@ def _fetch_submission_scoped(conn, account_id: int, submission_id: int):
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
# Campuri afisate in detaliul trimiterii (panou dedicat US-004). payload_json e
|
# Campuri afisate in detaliul trimiterii. payload_json e plaintext si se foloseste
|
||||||
# plaintext si se foloseste doar pentru campurile derivate (prezentare_din_payload).
|
# doar pentru campurile derivate (prezentare_din_payload).
|
||||||
@router.get("/_fragments/trimitere/{submission_id}", response_class=HTMLResponse)
|
@router.get("/_fragments/trimitere/{submission_id}", response_class=HTMLResponse)
|
||||||
def fragment_trimitere_detaliu(request: Request, submission_id: int) -> HTMLResponse:
|
def fragment_trimitere_detaliu(request: Request, submission_id: int) -> HTMLResponse:
|
||||||
"""Detaliu complet al unei trimiteri, in panou dedicat (#trimitere-detaliu).
|
"""Detaliu complet al unei trimiteri, in panou dedicat (#trimitere-detaliu).
|
||||||
|
|
||||||
Scoped pe contul sesiunii: 404 daca randul nu exista SAU apartine altui cont
|
Scoped pe contul sesiunii: 404 daca randul nu exista SAU apartine altui cont
|
||||||
(acelasi mesaj, nu confirmam existenta — vezi B3/router.py).
|
(acelasi mesaj, nu confirmam existenta).
|
||||||
"""
|
"""
|
||||||
account_id = require_login(request)
|
account_id = require_login(request)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -1021,7 +1017,7 @@ def fragment_trimitere_detaliu(request: Request, submission_id: int) -> HTMLResp
|
|||||||
|
|
||||||
@router.post("/trimitere/{submission_id}/mapeaza", response_class=HTMLResponse)
|
@router.post("/trimitere/{submission_id}/mapeaza", response_class=HTMLResponse)
|
||||||
async def post_mapeaza_inline(request: Request, submission_id: int) -> HTMLResponse:
|
async def post_mapeaza_inline(request: Request, submission_id: int) -> HTMLResponse:
|
||||||
"""Mapare inline din panoul de detaliu (PRD 5.7): alege cod RAR pentru o operatie nemapata.
|
"""Mapare inline din panoul de detaliu: alege cod RAR pentru o operatie nemapata.
|
||||||
|
|
||||||
Reutilizeaza EXACT save_mapping + reresolve_account (ca tab-ul Mapari) — fara logica
|
Reutilizeaza EXACT save_mapping + reresolve_account (ca tab-ul Mapari) — fara logica
|
||||||
noua de clasificare. Re-rezolva scoped pe batch-ul randului (canal API batch_id IS NULL
|
noua de clasificare. Re-rezolva scoped pe batch-ul randului (canal API batch_id IS NULL
|
||||||
@@ -1108,10 +1104,9 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
|
|||||||
if isinstance(val, str) and val.strip() != "":
|
if isinstance(val, str) and val.strip() != "":
|
||||||
content[camp] = val.strip()
|
content[camp] = val.strip()
|
||||||
|
|
||||||
# US-006: injectare cod_prestatie din form INAINTE de resolve_prestatii.
|
# Injectare cod_prestatie din form INAINTE de resolve_prestatii. Oglindeste
|
||||||
# Oglindeste validarea din post_mapeaza_inline (nomenclator check). Codul nou
|
# validarea din post_mapeaza_inline (nomenclator check). Codul nou e injectat in
|
||||||
# e injectat in prima prestatie (index 0); build_key il include in hash (CLAUDE.md
|
# prima prestatie (index 0); build_key il include in hash.
|
||||||
# invariant "build_key hashuieste cod_prestatie, idempotency.py:34").
|
|
||||||
_cod_raw = form.get("cod_prestatie")
|
_cod_raw = form.get("cod_prestatie")
|
||||||
cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "")
|
cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "")
|
||||||
if cod_prestatie_form:
|
if cod_prestatie_form:
|
||||||
@@ -1143,7 +1138,7 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
|
|||||||
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules)
|
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules)
|
||||||
content["prestatii"] = resolved
|
content["prestatii"] = resolved
|
||||||
|
|
||||||
# US-010: telemetrie pentru itemii rezolvati prin regula text (calea corectie web).
|
# telemetrie pentru itemii rezolvati prin regula text (calea corectie web).
|
||||||
_emite_text_rule_hits(conn, account_id, row["id"], resolved)
|
_emite_text_rule_hits(conn, account_id, row["id"], resolved)
|
||||||
|
|
||||||
# Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie.
|
# Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie.
|
||||||
@@ -1238,8 +1233,8 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
|
|||||||
"_trimitere_detaliu.html",
|
"_trimitere_detaliu.html",
|
||||||
_detaliu_ctx(request, row2, message="Corectat — randul a fost re-pus in coada."),
|
_detaliu_ctx(request, row2, message="Corectat — randul a fost re-pus in coada."),
|
||||||
)
|
)
|
||||||
# PRD 5.9 US-003 (R5): pe succes, lista se reincarca (trimiteriChanged) si modalul
|
# Pe succes, lista se reincarca (trimiteriChanged) si modalul se inchide
|
||||||
# se inchide (inchideModal). After-settle ca inchiderea sa urmeze swap-ul fragmentului.
|
# (inchideModal). After-settle ca inchiderea sa urmeze swap-ul fragmentului.
|
||||||
resp.headers["HX-Trigger-After-Settle"] = "trimiteriChanged, inchideModal"
|
resp.headers["HX-Trigger-After-Settle"] = "trimiteriChanged, inchideModal"
|
||||||
return resp
|
return resp
|
||||||
finally:
|
finally:
|
||||||
@@ -1247,26 +1242,26 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
|
|||||||
|
|
||||||
|
|
||||||
# =========================================================================== #
|
# =========================================================================== #
|
||||||
# US-011 — Lifecycle trimiteri blocate din dashboard: sterge / re-pune in coada #
|
# Lifecycle trimiteri blocate din dashboard: sterge / re-pune in coada. #
|
||||||
# Peste helper-ul US-009 (submissions_admin). CSRF enforce; scoped pe sesiune. #
|
# Peste helper-ul submissions_admin. CSRF enforce; scoped pe sesiune. #
|
||||||
# =========================================================================== #
|
# =========================================================================== #
|
||||||
|
|
||||||
@router.post("/trimitere/{submission_id}/repune", response_class=HTMLResponse)
|
@router.post("/trimitere/{submission_id}/repune", response_class=HTMLResponse)
|
||||||
async def post_repune_trimitere(request: Request, submission_id: int) -> HTMLResponse:
|
async def post_repune_trimitere(request: Request, submission_id: int) -> HTMLResponse:
|
||||||
"""Re-pune in coada un rand blocat (error/needs_data/needs_mapping) din dashboard.
|
"""Re-pune in coada un rand blocat (error/needs_data/needs_mapping) din dashboard.
|
||||||
|
|
||||||
US-006b: daca randul e in starea `error` si formularul contine `cod_prestatie`,
|
Daca randul e in starea `error` si formularul contine `cod_prestatie`, actualizeaza
|
||||||
actualizeaza codul in payload, recalculeaza cheia de idempotency si re-pun in coada
|
codul in payload, recalculeaza cheia de idempotency si re-pune in coada direct (fara
|
||||||
direct (fara `requeue_submission`, care nu actualizeaza cheia). Scoped pe sesiune
|
`requeue_submission`, care nu actualizeaza cheia). Scoped pe sesiune (404
|
||||||
(404 cross-account/inexistent, 409 sent/sending). Re-randeaza panoul de detaliu cu
|
cross-account/inexistent, 409 sent/sending). Re-randeaza panoul de detaliu cu starea
|
||||||
starea noua + nudge `trimiteriChanged` pentru lista.
|
noua + nudge `trimiteriChanged` pentru lista.
|
||||||
"""
|
"""
|
||||||
account_id = require_login(request)
|
account_id = require_login(request)
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
verify_csrf(request, str(form.get("csrf_token") or ""))
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
# US-006b: prelucrare cod_prestatie pentru starea error (inaintea requeue_submission
|
# Prelucrare cod_prestatie pentru starea error (inaintea requeue_submission
|
||||||
# standard, care nu actualizeaza cheia de idempotency).
|
# standard, care nu actualizeaza cheia de idempotency).
|
||||||
_cod_raw = form.get("cod_prestatie")
|
_cod_raw = form.get("cod_prestatie")
|
||||||
cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "")
|
cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "")
|
||||||
@@ -1398,7 +1393,7 @@ async def post_sterge_trimitere(request: Request, submission_id: int) -> HTMLRes
|
|||||||
resp = HTMLResponse(
|
resp = HTMLResponse(
|
||||||
'<div class="flash" style="margin:0;">Trimitere stearsa.</div>'
|
'<div class="flash" style="margin:0;">Trimitere stearsa.</div>'
|
||||||
)
|
)
|
||||||
# PRD 5.9 US-003 (R5): pe succes, lista se reincarca + modalul se inchide.
|
# Pe succes, lista se reincarca + modalul se inchide.
|
||||||
resp.headers["HX-Trigger-After-Settle"] = "trimiteriChanged, inchideModal"
|
resp.headers["HX-Trigger-After-Settle"] = "trimiteriChanged, inchideModal"
|
||||||
return resp
|
return resp
|
||||||
finally:
|
finally:
|
||||||
@@ -1410,7 +1405,7 @@ async def post_sterge_bulk(request: Request) -> HTMLResponse:
|
|||||||
"""Sterge in bloc trimiterile selectate (doar blocate, scoped pe sesiune).
|
"""Sterge in bloc trimiterile selectate (doar blocate, scoped pe sesiune).
|
||||||
|
|
||||||
Sare peste randuri sent/sending (read-only) si cross-account (inexistente) fara a
|
Sare peste randuri sent/sending (read-only) si cross-account (inexistente) fara a
|
||||||
opri operatia — pe modelul panoului admin (PRD 5.5). Re-randeaza lista Trimiteri.
|
opri operatia — pe modelul panoului admin. Re-randeaza lista Trimiteri.
|
||||||
"""
|
"""
|
||||||
account_id = require_login(request)
|
account_id = require_login(request)
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
@@ -1436,7 +1431,7 @@ async def post_sterge_bulk(request: Request) -> HTMLResponse:
|
|||||||
|
|
||||||
def _load_saved_op_mappings(conn, account_id: int) -> list[dict]:
|
def _load_saved_op_mappings(conn, account_id: int) -> list[dict]:
|
||||||
"""Mapari operatie->cod salvate (operations_mapping) ale contului, cu numele
|
"""Mapari operatie->cod salvate (operations_mapping) ale contului, cu numele
|
||||||
prestatiei jonctionat din nomenclator (US-005). Scoped pe cont (NOT NULL → simplu)."""
|
prestatiei jonctionat din nomenclator. Scoped pe cont (NOT NULL → simplu)."""
|
||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT o.id, o.cod_op_service, o.cod_prestatie, o.auto_send, n.nume_prestatie "
|
"SELECT o.id, o.cod_op_service, o.cod_prestatie, o.auto_send, n.nume_prestatie "
|
||||||
@@ -1458,7 +1453,7 @@ def _load_saved_op_mappings(conn, account_id: int) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def _load_column_formats(conn, account_id: int) -> list[dict]:
|
def _load_column_formats(conn, account_id: int) -> list[dict]:
|
||||||
"""Formate de coloane salvate (column_mappings) ale contului (US-006).
|
"""Formate de coloane salvate (column_mappings) ale contului.
|
||||||
|
|
||||||
Coloanele afisate = cheile din json_mapare (campurile recunoscute). Scoped pe cont.
|
Coloanele afisate = cheile din json_mapare (campurile recunoscute). Scoped pe cont.
|
||||||
"""
|
"""
|
||||||
@@ -1507,7 +1502,7 @@ def _render_mapari(
|
|||||||
def fragment_mapari(request: Request) -> HTMLResponse:
|
def fragment_mapari(request: Request) -> HTMLResponse:
|
||||||
"""Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR.
|
"""Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR.
|
||||||
|
|
||||||
Scoped pe contul sesiunii (C6/task#7): pending_unmapped primeste account_id explicit.
|
Scoped pe contul sesiunii: pending_unmapped primeste account_id explicit.
|
||||||
"""
|
"""
|
||||||
account_id = require_login(request)
|
account_id = require_login(request)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -1547,7 +1542,7 @@ def post_mapare(
|
|||||||
|
|
||||||
|
|
||||||
# =========================================================================== #
|
# =========================================================================== #
|
||||||
# US-005 — Mapari operatii salvate: editare cod/auto-send + stergere #
|
# Mapari operatii salvate: editare cod/auto-send + stergere. #
|
||||||
# CRUD pe operations_mapping scoped pe sesiune; re-rezolva blocatele la edit. #
|
# CRUD pe operations_mapping scoped pe sesiune; re-rezolva blocatele la edit. #
|
||||||
# =========================================================================== #
|
# =========================================================================== #
|
||||||
|
|
||||||
@@ -1611,7 +1606,7 @@ def post_sterge_mapare_salvata(
|
|||||||
|
|
||||||
|
|
||||||
# =========================================================================== #
|
# =========================================================================== #
|
||||||
# US-004 (5.8) — Reguli automate (text): substring -> cod RAR #
|
# Reguli automate (text): substring -> cod RAR. #
|
||||||
# Adaugare/stergere reguli text scoped pe sesiune; salvarea re-rezolva blocajele.#
|
# Adaugare/stergere reguli text scoped pe sesiune; salvarea re-rezolva blocajele.#
|
||||||
# =========================================================================== #
|
# =========================================================================== #
|
||||||
|
|
||||||
@@ -1628,7 +1623,7 @@ def post_salveaza_regula_text(
|
|||||||
Scoped pe contul sesiunii (save_text_rule foloseste account_or_default(sesiune)).
|
Scoped pe contul sesiunii (save_text_rule foloseste account_or_default(sesiune)).
|
||||||
Valideaza cod_prestatie fata de nomenclator INAINTE de save (cod necunoscut ->
|
Valideaza cod_prestatie fata de nomenclator INAINTE de save (cod necunoscut ->
|
||||||
respins inline, fara salvare). La succes: mesaj „Regula salvata. Deblocate: N"
|
respins inline, fara salvare). La succes: mesaj „Regula salvata. Deblocate: N"
|
||||||
+ trigger trimiteriChanged (refresh lista), ca maparea inline (5.7).
|
+ trigger trimiteriChanged (refresh lista), ca maparea inline.
|
||||||
"""
|
"""
|
||||||
account_id = require_login(request)
|
account_id = require_login(request)
|
||||||
verify_csrf(request, csrf_token)
|
verify_csrf(request, csrf_token)
|
||||||
@@ -1647,7 +1642,7 @@ def post_salveaza_regula_text(
|
|||||||
request, conn, account_id,
|
request, conn, account_id,
|
||||||
message=f"Cod RAR necunoscut in nomenclator: {cod}.",
|
message=f"Cod RAR necunoscut in nomenclator: {cod}.",
|
||||||
)
|
)
|
||||||
# US-011: avertisment neblocant daca regula noua se suprapune (substring,
|
# avertisment neblocant daca regula noua se suprapune (substring,
|
||||||
# oricare directie) cu una existenta. Calculam INAINTE de save, fata de
|
# oricare directie) cu una existenta. Calculam INAINTE de save, fata de
|
||||||
# regulile curente, ca pattern-ul nou sa nu se compare cu sine.
|
# regulile curente, ca pattern-ul nou sa nu se compare cu sine.
|
||||||
overlap = text_rules_overlap(pat, load_text_rules(conn, account_id))
|
overlap = text_rules_overlap(pat, load_text_rules(conn, account_id))
|
||||||
@@ -1696,7 +1691,7 @@ def post_preview_regula_text(
|
|||||||
pattern: str = Form(""),
|
pattern: str = Form(""),
|
||||||
csrf_token: str | None = Form(None),
|
csrf_token: str | None = Form(None),
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Preview pre-salvare (US-009): cate operatii nemapate ar potrivi regula.
|
"""Preview pre-salvare: cate operatii nemapate ar potrivi regula.
|
||||||
|
|
||||||
NU salveaza nimic (zero scriere DB). Normalizeaza pattern-ul cu
|
NU salveaza nimic (zero scriere DB). Normalizeaza pattern-ul cu
|
||||||
normalize_for_match, numara operatiile DISTINCTE nemapate ale contului
|
normalize_for_match, numara operatiile DISTINCTE nemapate ale contului
|
||||||
@@ -1743,7 +1738,7 @@ def post_preview_regula_text(
|
|||||||
|
|
||||||
|
|
||||||
# =========================================================================== #
|
# =========================================================================== #
|
||||||
# US-006 — Formate de coloane salvate: editare format data + stergere #
|
# Formate de coloane salvate: editare format data + stergere. #
|
||||||
# CRUD pe column_mappings scoped pe sesiune (prin id, verificat pe account). #
|
# CRUD pe column_mappings scoped pe sesiune (prin id, verificat pe account). #
|
||||||
# =========================================================================== #
|
# =========================================================================== #
|
||||||
|
|
||||||
@@ -1879,7 +1874,7 @@ def _web_compute_preview(
|
|||||||
if not raw_rows_db:
|
if not raw_rows_db:
|
||||||
return "Niciun rand in batch."
|
return "Niciun rand in batch."
|
||||||
|
|
||||||
# Decripteaza randurile + override-urile editate (3.6)
|
# Decripteaza randurile + override-urile editate
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
overrides: list[dict[str, Any]] = []
|
overrides: list[dict[str, Any]] = []
|
||||||
for r in raw_rows_db:
|
for r in raw_rows_db:
|
||||||
@@ -1907,12 +1902,12 @@ def _web_compute_preview(
|
|||||||
json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"])
|
json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"])
|
||||||
format_data: str | None = mapping_row["format_data"]
|
format_data: str | None = mapping_row["format_data"]
|
||||||
|
|
||||||
# Mapare operatii (o singura incarcare — Eng#5)
|
# Mapare operatii (o singura incarcare)
|
||||||
mapping_meta = load_mapping_meta(conn, acct)
|
mapping_meta = load_mapping_meta(conn, acct)
|
||||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||||
# US-010/US-003 (paritate): preview-ul web trebuie sa aplice ACELEASI reguli text +
|
# Paritate cu commit-ul: preview-ul web trebuie sa aplice ACELEASI reguli text +
|
||||||
# validare nomenclator ca si commit-ul (2426), altfel un rand rezolvabil doar prin
|
# validare nomenclator, altfel un rand rezolvabil doar prin regula text ar fi marcat
|
||||||
# regula text ar fi marcat needs_mapping si exclus din commit. Incarcate o data.
|
# needs_mapping si exclus din commit. Incarcate o data.
|
||||||
valid_codes = load_nomenclator_codes(conn) or None
|
valid_codes = load_nomenclator_codes(conn) or None
|
||||||
text_rules = load_text_rules(conn, acct)
|
text_rules = load_text_rules(conn, acct)
|
||||||
|
|
||||||
@@ -1980,7 +1975,7 @@ def _web_compute_preview(
|
|||||||
"idempotency_key": key,
|
"idempotency_key": key,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Already_sent: batch lookup (Eng#5 — fara N+1)
|
# Already_sent: batch lookup (fara N+1)
|
||||||
unique_keys = list(set(keys_for_lookup))
|
unique_keys = list(set(keys_for_lookup))
|
||||||
already_sent_map = _already_sent_lookup(conn, account_id, unique_keys)
|
already_sent_map = _already_sent_lookup(conn, account_id, unique_keys)
|
||||||
|
|
||||||
@@ -2091,7 +2086,7 @@ async def web_upload_import(
|
|||||||
try:
|
try:
|
||||||
sig = _signature(parsed.columns)
|
sig = _signature(parsed.columns)
|
||||||
|
|
||||||
# Stagingul in DB (tranzactie explicita — Issue 6)
|
# Stagingul in DB (tranzactie explicita)
|
||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
try:
|
try:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
@@ -2330,15 +2325,15 @@ def web_preview_import(
|
|||||||
|
|
||||||
|
|
||||||
# =========================================================================== #
|
# =========================================================================== #
|
||||||
# US-002 (3.6) — Editare celule in preview: mod editare pe rand. #
|
# Editare celule in preview: mod editare pe rand. #
|
||||||
# Swap pe rand (#preview-row-N) + OOB contoare, NU pe #import-section (D-3.1). #
|
# Swap pe rand (#preview-row-N) + OOB contoare, NU pe #import-section. #
|
||||||
# Status rederivat DOAR prin _resolve_row_for_preview (H2 — fara clasificator). #
|
# Status rederivat DOAR prin _resolve_row_for_preview (fara clasificator). #
|
||||||
# =========================================================================== #
|
# =========================================================================== #
|
||||||
|
|
||||||
def _preview_one_row(conn, import_id: int, account_id: int, row_index: int):
|
def _preview_one_row(conn, import_id: int, account_id: int, row_index: int):
|
||||||
"""Recalculeaza preview-ul si extrage un singur rand.
|
"""Recalculeaza preview-ul si extrage un singur rand.
|
||||||
|
|
||||||
Statusul e rederivat prin `_resolve_row_for_preview` (H2, fara clasificator duplicat),
|
Statusul e rederivat prin `_resolve_row_for_preview` (fara clasificator duplicat),
|
||||||
iar `_web_compute_preview` persista `resolved_status` pentru toate randurile — astfel
|
iar `_web_compute_preview` persista `resolved_status` pentru toate randurile — astfel
|
||||||
confirmarea (commit) vede starea editata. Intoarce (result, row) sau (mesaj, None)."""
|
confirmarea (commit) vede starea editata. Intoarce (result, row) sau (mesaj, None)."""
|
||||||
result = _web_compute_preview(conn, import_id, account_id)
|
result = _web_compute_preview(conn, import_id, account_id)
|
||||||
@@ -2366,7 +2361,7 @@ def _render_preview_rand(
|
|||||||
|
|
||||||
@router.get("/_import/{import_id}/rand/{row_index}/editare", response_class=HTMLResponse)
|
@router.get("/_import/{import_id}/rand/{row_index}/editare", response_class=HTMLResponse)
|
||||||
def web_rand_editare(request: Request, import_id: int, row_index: int) -> HTMLResponse:
|
def web_rand_editare(request: Request, import_id: int, row_index: int) -> HTMLResponse:
|
||||||
"""Intra in mod editare pe un rand de preview (randul devine FORM propriu, D-3.3)."""
|
"""Intra in mod editare pe un rand de preview (randul devine FORM propriu)."""
|
||||||
account_id = require_login(request)
|
account_id = require_login(request)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
@@ -2400,11 +2395,11 @@ def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLRe
|
|||||||
|
|
||||||
@router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse)
|
@router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse)
|
||||||
async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse:
|
async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse:
|
||||||
"""Alias web (US-001/US-002): persista override (mutatie pura) + re-randeaza DOAR randul.
|
"""Persista override (mutatie pura) + re-randeaza DOAR randul.
|
||||||
|
|
||||||
Statusul e rederivat prin `_resolve_row_for_preview` (H2). Swap pe rand + OOB
|
Statusul e rederivat prin `_resolve_row_for_preview`. Swap pe rand + OOB contoare.
|
||||||
contoare (D-3.1). Daca raman erori de continut pe camp, randul ramane in editare
|
Daca raman erori de continut pe camp, randul ramane in editare cu valorile pastrate
|
||||||
cu valorile pastrate si mesajul pe campul vinovat (D-2.1/D-2.2)."""
|
si mesajul pe campul vinovat."""
|
||||||
account_id = require_login(request)
|
account_id = require_login(request)
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
verify_csrf(request, str(form.get("csrf_token") or ""))
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
||||||
@@ -2502,8 +2497,8 @@ async def web_confirma_import(
|
|||||||
|
|
||||||
Replica logica din import_router.commit_import dar cu input din form HTML
|
Replica logica din import_router.commit_import dar cu input din form HTML
|
||||||
si raspuns HTML (nu JSON). INSERT per-rand ON CONFLICT DO NOTHING (TOCTOU).
|
si raspuns HTML (nu JSON). INSERT per-rand ON CONFLICT DO NOTHING (TOCTOU).
|
||||||
C8/OV-2: account_id din sesiune propagat consecvent la build_key si toate lookup-urile.
|
account_id din sesiune propagat consecvent la build_key si toate lookup-urile.
|
||||||
C12: require_login — pe scrieri NICIODATA fallback cont 1 in prod.
|
require_login — pe scrieri NICIODATA fallback cont 1 in prod.
|
||||||
"""
|
"""
|
||||||
account_id = require_login(request)
|
account_id = require_login(request)
|
||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
@@ -2644,11 +2639,11 @@ async def web_confirma_import(
|
|||||||
# Mapare operatii
|
# Mapare operatii
|
||||||
mapping_meta = load_mapping_meta(conn, acct)
|
mapping_meta = load_mapping_meta(conn, acct)
|
||||||
mapping_ops = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
mapping_ops = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||||
# T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
# validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
||||||
valid_codes = load_nomenclator_codes(conn) or None
|
valid_codes = load_nomenclator_codes(conn) or None
|
||||||
text_rules = load_text_rules(conn, acct)
|
text_rules = load_text_rules(conn, acct)
|
||||||
|
|
||||||
# Enqueue in tranzactie explicita (Issue 6) — INSERT ON CONFLICT DO NOTHING (TOCTOU)
|
# Enqueue in tranzactie explicita — INSERT ON CONFLICT DO NOTHING (TOCTOU)
|
||||||
enqueued: list[dict] = []
|
enqueued: list[dict] = []
|
||||||
toctou: list[int] = []
|
toctou: list[int] = []
|
||||||
rows_for_hash: list[str] = []
|
rows_for_hash: list[str] = []
|
||||||
@@ -2696,7 +2691,7 @@ async def web_confirma_import(
|
|||||||
"odometru_final": canon["odometru_final"],
|
"odometru_final": canon["odometru_final"],
|
||||||
})
|
})
|
||||||
|
|
||||||
# Override editat in preview (3.6) — aplicat ULTIMUL, ca in resolver.
|
# Override editat in preview — aplicat ULTIMUL, ca in resolver.
|
||||||
override = item.get("override") or {}
|
override = item.get("override") or {}
|
||||||
if override:
|
if override:
|
||||||
mapped.update(override)
|
mapped.update(override)
|
||||||
@@ -2729,7 +2724,7 @@ async def web_confirma_import(
|
|||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
toctou.append(row_index)
|
toctou.append(row_index)
|
||||||
else:
|
else:
|
||||||
# US-010: telemetrie pentru itemii rezolvati prin regula text.
|
# telemetrie pentru itemii rezolvati prin regula text.
|
||||||
_emite_text_rule_hits(conn, acct, int(cur.lastrowid), resolved_p)
|
_emite_text_rule_hits(conn, acct, int(cur.lastrowid), resolved_p)
|
||||||
enqueued.append({"submission_id": cur.lastrowid, "row_index": row_index})
|
enqueued.append({"submission_id": cur.lastrowid, "row_index": row_index})
|
||||||
|
|
||||||
@@ -2740,7 +2735,7 @@ async def web_confirma_import(
|
|||||||
|
|
||||||
n_enqueued = len(enqueued)
|
n_enqueued = len(enqueued)
|
||||||
|
|
||||||
# Log atestare (Voce#9)
|
# Log atestare
|
||||||
rows_hash = hashlib.sha256(
|
rows_hash = hashlib.sha256(
|
||||||
json.dumps(sorted(rows_for_hash), ensure_ascii=False).encode("utf-8")
|
json.dumps(sorted(rows_for_hash), ensure_ascii=False).encode("utf-8")
|
||||||
).hexdigest() if rows_for_hash else ""
|
).hexdigest() if rows_for_hash else ""
|
||||||
@@ -2757,7 +2752,7 @@ async def web_confirma_import(
|
|||||||
|
|
||||||
# Succes → bara de upload slim cu mesaj de confirmare. are_trimiteri=True:
|
# Succes → bara de upload slim cu mesaj de confirmare. are_trimiteri=True:
|
||||||
# contul tocmai a pus randuri in coada -> bara ramane slim si dezvaluie
|
# contul tocmai a pus randuri in coada -> bara ramane slim si dezvaluie
|
||||||
# sectiunea "Trimiterile tale" de pe Acasa (US-003/US-004).
|
# sectiunea "Trimiterile tale" de pe Acasa.
|
||||||
toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else ""
|
toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else ""
|
||||||
return templates.TemplateResponse("_upload.html", _ctx(
|
return templates.TemplateResponse("_upload.html", _ctx(
|
||||||
request,
|
request,
|
||||||
@@ -2773,8 +2768,8 @@ async def web_confirma_import(
|
|||||||
|
|
||||||
|
|
||||||
# =========================================================================== #
|
# =========================================================================== #
|
||||||
# US-007 — Sectiune "Contul meu": rotire cheie API + creds RAR din UI #
|
# Sectiune "Contul meu": rotire cheie API + creds RAR din UI. #
|
||||||
# Rute web proprii scoped pe sesiune (C13: nu reutilizeaza /v1/conturi/rar-creds
|
# Rute web proprii scoped pe sesiune (nu reutilizeaza /v1/conturi/rar-creds #
|
||||||
# care cere cheie API; sesiunea web e suficienta ca identitate). #
|
# care cere cheie API; sesiunea web e suficienta ca identitate). #
|
||||||
# =========================================================================== #
|
# =========================================================================== #
|
||||||
|
|
||||||
@@ -2846,10 +2841,9 @@ def integrare_test_cheie(
|
|||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Verifica cheia API lipita de utilizator — scoped pe contul sesiunii.
|
"""Verifica cheia API lipita de utilizator — scoped pe contul sesiunii.
|
||||||
|
|
||||||
US-004 (PRD Etapa 5): permite utilizatorului sa confirme ca o cheie copiata
|
Permite utilizatorului sa confirme ca o cheie copiata din generatorul de exemple
|
||||||
din generatorul de exemple corespunde contului sau, fara efecte secundare
|
corespunde contului sau, fara efecte secundare (fara creare/rotire). Cheie goala,
|
||||||
(fara creare/rotire). Cheie goala, invalida sau a altui cont -> mesaj de
|
invalida sau a altui cont -> mesaj de eroare neutru (fara eco al cheii in raspuns).
|
||||||
eroare neutru (fara eco al cheii in raspuns).
|
|
||||||
"""
|
"""
|
||||||
account_id = require_login(request)
|
account_id = require_login(request)
|
||||||
verify_csrf(request, csrf_token)
|
verify_csrf(request, csrf_token)
|
||||||
@@ -2901,7 +2895,7 @@ def cont_rar_creds(
|
|||||||
rar_parola: str = Form(""),
|
rar_parola: str = Form(""),
|
||||||
csrf_token: str | None = Form(None),
|
csrf_token: str | None = Form(None),
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Seteaza creds RAR per cont din sesiune (ruta web proprie, C13).
|
"""Seteaza creds RAR per cont din sesiune (ruta web proprie).
|
||||||
|
|
||||||
Camp parola NICIODATA re-pus in value= la re-randare.
|
Camp parola NICIODATA re-pus in value= la re-randare.
|
||||||
Validare minima: email si parola negoale.
|
Validare minima: email si parola negoale.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Helper-e sesiune web. US-002 PRD 3.3.
|
"""Helper-e sesiune web.
|
||||||
|
|
||||||
Mecanism require_login (C11): NU un dependency FastAPI care intoarce RedirectResponse
|
Mecanism require_login: NU un dependency FastAPI care intoarce RedirectResponse
|
||||||
(acela nu scurtcircuiteaza handler-ul — FastAPI continua executia). In schimb:
|
(acela nu scurtcircuiteaza handler-ul — FastAPI continua executia). In schimb:
|
||||||
- require_login() RIDICA LoginRequired
|
- require_login() RIDICA LoginRequired
|
||||||
- app.main inregistreaza @app.exception_handler(LoginRequired) care intoarce
|
- app.main inregistreaza @app.exception_handler(LoginRequired) care intoarce
|
||||||
@@ -31,7 +31,7 @@ def current_account(request: Request) -> int | None:
|
|||||||
|
|
||||||
|
|
||||||
def current_user_id(request: Request) -> int | None:
|
def current_user_id(request: Request) -> int | None:
|
||||||
"""user_id din sesiune sau None (C19: leaga import_attestations.confirmed_by)."""
|
"""user_id din sesiune sau None (leaga import_attestations.confirmed_by)."""
|
||||||
val = request.session.get("user_id")
|
val = request.session.get("user_id")
|
||||||
return int(val) if val is not None else None
|
return int(val) if val is not None else None
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ def require_admin(request: Request) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def set_session(request: Request, account_id: int, user_id: int) -> None:
|
def set_session(request: Request, account_id: int, user_id: int) -> None:
|
||||||
"""Seteaza sesiunea dupa login. Curata mai intai (C3 anti-fixare sesiune)."""
|
"""Seteaza sesiunea dupa login. Curata mai intai (anti-fixare sesiune)."""
|
||||||
request.session.clear()
|
request.session.clear()
|
||||||
request.session["account_id"] = account_id
|
request.session["account_id"] = account_id
|
||||||
request.session["user_id"] = user_id
|
request.session["user_id"] = user_id
|
||||||
|
|||||||
@@ -44,12 +44,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# US-001 (5.5): randul "Ajutor" (wayfinding Mapari/Coduri RAR) eliminat — navigarea
|
{# Sectiunea Trimiteri, permanenta sub upload. Suprimata la first-run (zero
|
||||||
traieste in tab-bar (Mapari) si in meniul de cont (Nomenclator etc.). #}
|
trimiteri): bara de upload acopera deja CTA-ul, iar empty-state-ul ar fi redundant. #}
|
||||||
|
|
||||||
{# === Sectiunea Trimiteri ("Trimiterile tale"), permanenta sub upload (US-003).
|
|
||||||
Suprimata la first-run (zero trimiteri): bara de upload acopera deja CTA-ul,
|
|
||||||
iar empty-state-ul tabelului ar fi redundant (US-004 / D-5.1). === #}
|
|
||||||
{% if are_trimiteri %}
|
{% if are_trimiteri %}
|
||||||
{% include '_coada.html' %}
|
{% include '_coada.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
_coada.html — repurposat in 3.6 (US-003).
|
_coada.html — sectiunea "Trimiterile tale" inclusa pe Acasa, sub zona de upload.
|
||||||
Nu mai e un tab/panou separat: e sectiunea "Trimiterile tale" inclusa pe Acasa,
|
Filtre + tabel (_submissions.html); detaliul se deschide in modalul global (#modal-detaliu).
|
||||||
sub zona de upload. Pastreaza filtrele (US-009) si tabelul (_submissions.html); detaliul
|
|
||||||
se deschide acum in modalul global (#modal-detaliu). Poll aliniat la 15s (anti dublu-poll, M5).
|
|
||||||
#}
|
#}
|
||||||
<section id="trimiteri-section" aria-labelledby="trimiteri-heading"
|
<section id="trimiteri-section" aria-labelledby="trimiteri-heading"
|
||||||
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);">
|
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);">
|
||||||
@@ -68,8 +66,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# PRD 5.9 US-003: detaliul s-a mutat intr-un MODAL global (#modal-detaliu in base.html),
|
|
||||||
in afara #submissions-wrap -> poll-ul de 15s nu-l mai atinge. Randul declanseaza
|
|
||||||
deschiderea (hx-target=#detaliu-modal-body). Vechiul panou inert #trimitere-detaliu
|
|
||||||
a fost eliminat (rol preluat de modal). #}
|
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{#
|
{#
|
||||||
_eroare.html — macro card_erori(erori) (US-006, PRD 5.4).
|
_eroare.html — macro card_erori(erori).
|
||||||
|
|
||||||
Primeste o lista de dict-uri cu cheile: problema, cauza, fix, field (sau None).
|
Primeste o lista de dict-uri cu cheile: problema, cauza, fix, field (sau None).
|
||||||
Afiseaza 3 niveluri intr-un bloc scannabil:
|
Afiseaza 3 niveluri intr-un bloc scannabil:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{# _jurnal.html — tab Jurnal de aplicatie (US-006, PRD 5.6).
|
{# _jurnal.html — tab Jurnal de aplicatie.
|
||||||
Lista paginata de evenimente (app_events), redactate la scriere. Filtre tip/nivel/
|
Lista paginata de evenimente (app_events), redactate la scriere. Filtre tip/nivel/
|
||||||
data + (admin) cont. Stil consistent cu tabelele PRD 5.5 (.tablewrap). #}
|
data + (admin) cont. #}
|
||||||
<section id="jurnal-section" aria-labelledby="jurnal-heading">
|
<section id="jurnal-section" aria-labelledby="jurnal-heading">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
|
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
{# Macro-uri partajate intre template-urile de import si mapari. #}
|
{# Macro-uri partajate intre template-urile de import si mapari. #}
|
||||||
|
|
||||||
{# US-003 (5.5): comutator COMPACT "In coada" — Manual <-> Auto pe un singur rand, fara
|
{# Comutator COMPACT "In coada" — Manual <-> Auto pe un singur rand.
|
||||||
proza inline (explicatia traieste o singura data in panoul Ajutor al cardului Mapari).
|
INVARIANT BACKEND: control = checkbox cu `name="auto_send" value="true"` si
|
||||||
Inlocuieste varianta verbose din 3.6 care injecta 3 randuri de text pe FIECARE linie de
|
SEMANTICA DE PREZENTA (bifat -> trimite "true" -> True; nebifat -> camp absent -> False).
|
||||||
tabel (randuri inalte -> Salveaza/Sterge ieseau din viewport).
|
|
||||||
|
|
||||||
INVARIANT BACKEND (nealterat din 3.6): control = checkbox cu `name="auto_send" value="true"`
|
|
||||||
si SEMANTICA DE PREZENTA (bifat -> trimite "true" -> True; nebifat -> camp absent -> False).
|
|
||||||
E singura forma compatibila cu AMBELE parsere: `Form(bool)` la /mapari SI `bool(form.get())`
|
E singura forma compatibila cu AMBELE parsere: `Form(bool)` la /mapari SI `bool(form.get())`
|
||||||
la /_import/.../mapare-operatie. Radio Auto/Manual cu value="false" ar trimite campul prezent
|
la /_import/.../mapare-operatie. Radio Auto/Manual cu value="false" ar trimite campul prezent
|
||||||
pe "Manual" -> `bool("false")` = True la import (regresie tacuta). De aceea comutator vizual
|
pe "Manual" -> `bool("false")` = True la import (regresie tacuta). De aceea comutator vizual
|
||||||
Manual<->Auto peste checkbox, NU doua radio-uri. Zero atingere backend.
|
Manual<->Auto peste checkbox, NU doua radio-uri.
|
||||||
- form_id: leaga input-ul de un <form> extern (necesar in celulele de tabel).
|
- form_id: leaga input-ul de un <form> extern (necesar in celulele de tabel).
|
||||||
- checked: starea STOCATA per mapare (H4) — bifat = Auto. #}
|
- checked: starea STOCATA per mapare — bifat = Auto. #}
|
||||||
{% macro autosend_toggle(form_id='', checked=True, label='') -%}
|
{% macro autosend_toggle(form_id='', checked=True, label='') -%}
|
||||||
<label class="autosend-toggle"
|
<label class="autosend-toggle"
|
||||||
title="Bifat = Auto: pune automat in coada la fisierele viitoare cu aceasta operatie. Nebifat = Manual: tine pentru verificare; nimic nu pleaca la RAR pana confirmi."
|
title="Bifat = Auto: pune automat in coada la fisierele viitoare cu aceasta operatie. Nebifat = Manual: tine pentru verificare; nimic nu pleaca la RAR pana confirmi."
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
/* Selectul de cod RAR e principalul vinovat de latimea tabelelor de mapari. Il limitam ca
|
/* Selectul de cod RAR e principalul vinovat de latimea tabelelor de mapari. Il limitam ca
|
||||||
tabelul sa incapa in card fara scroll orizontal -> coloana Actiuni (kebab) ramane vizibila. */
|
tabelul sa incapa in card fara scroll orizontal -> coloana Actiuni (kebab) ramane vizibila. */
|
||||||
#mapari-section td select { width:100%; max-width:240px; min-width:150px; }
|
#mapari-section td select { width:100%; max-width:240px; min-width:150px; }
|
||||||
/* US-007 (R12): in card per rand (sub 767px) selectul/inputurile umplu cardul. */
|
/* In card per rand (sub 767px) selectul/inputurile umplu cardul. */
|
||||||
@media (max-width:767px) {
|
@media (max-width:767px) {
|
||||||
#mapari-section td select, #mapari-section td input[type=text] { max-width:none; min-width:0; }
|
#mapari-section td select, #mapari-section td input[type=text] { max-width:none; min-width:0; }
|
||||||
}
|
}
|
||||||
@@ -18,11 +18,6 @@
|
|||||||
<!-- Sectiunea 1: De rezolvat (operatii needs_mapping) -->
|
<!-- Sectiunea 1: De rezolvat (operatii needs_mapping) -->
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
{# US-005 (5.5): antet standard + link Ajutor ca <details> nativ (fara JS). Toata proza
|
|
||||||
care inainte se repeta inline (scopul maparilor, Auto/Manual) traieste acum AICI,
|
|
||||||
o singura data, ascunsa implicit. #}
|
|
||||||
{# US-010: sectiunea de ajutor (details.ajutor-mapari) eliminata.
|
|
||||||
Empty-state „Nicio operatie nemapata" eliminat — sectiunea ramane goala (fara text). #}
|
|
||||||
<h2 style="font-size:15px; margin:0 0 8px;">De rezolvat</h2>
|
<h2 style="font-size:15px; margin:0 0 8px;">De rezolvat</h2>
|
||||||
|
|
||||||
{% if pending %}
|
{% if pending %}
|
||||||
@@ -102,8 +97,6 @@
|
|||||||
Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand.
|
Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand.
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{# US-005 (5.5): proza explicativa mutata in panoul Ajutor de la "De rezolvat" (o singura data). #}
|
|
||||||
|
|
||||||
<div data-dt="10">
|
<div data-dt="10">
|
||||||
<div class="dt-tools">
|
<div class="dt-tools">
|
||||||
<input type="search" data-dt-search class="dt-search"
|
<input type="search" data-dt-search class="dt-search"
|
||||||
@@ -150,7 +143,7 @@
|
|||||||
{{ ui.autosend_toggle(form_id="map-salv-" ~ loop.index, checked=m.auto_send) }}
|
{{ ui.autosend_toggle(form_id="map-salv-" ~ loop.index, checked=m.auto_send) }}
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align:right; white-space:nowrap;" data-eticheta="Actiuni">
|
<td style="text-align:right; white-space:nowrap;" data-eticheta="Actiuni">
|
||||||
{# US-011: butoane icon mereu vizibile (fara kebab). SVG aria-hidden; aria-label pe buton.
|
{# Butoane icon mereu vizibile (fara kebab). SVG aria-hidden; aria-label pe buton.
|
||||||
data-dirty-form e citit de JS din base.html: la schimbarea select-ului din acelasi rand,
|
data-dirty-form e citit de JS din base.html: la schimbarea select-ului din acelasi rand,
|
||||||
JS adauga clasa "dirty" pe butonul de salvare (fundal --accent = modificari nesalvate). #}
|
JS adauga clasa "dirty" pe butonul de salvare (fundal --accent = modificari nesalvate). #}
|
||||||
<button type="submit" form="map-salv-{{ loop.index }}"
|
<button type="submit" form="map-salv-{{ loop.index }}"
|
||||||
@@ -182,7 +175,6 @@
|
|||||||
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<!-- Sectiunea 3: Reguli automate pe text (operation_text_rules) -->
|
<!-- Sectiunea 3: Reguli automate pe text (operation_text_rules) -->
|
||||||
<!-- US-010: mutata pe pozitia 3 (inainte de Formate de coloane) -->
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="font-size:15px; margin:0 0 8px;">Reguli automate (text)</h2>
|
<h2 style="font-size:15px; margin:0 0 8px;">Reguli automate (text)</h2>
|
||||||
@@ -266,7 +258,7 @@
|
|||||||
<button type="submit" form="rt-add">Adauga</button>
|
<button type="submit" form="rt-add">Adauga</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{# Preview pre-salvare (US-009): cate operatii nemapate potriveste pattern-ul. #}
|
{# Preview pre-salvare: cate operatii nemapate potriveste pattern-ul. #}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" style="padding-top:0;">
|
<td colspan="4" style="padding-top:0;">
|
||||||
<div id="rt-preview" aria-live="polite"></div>
|
<div id="rt-preview" aria-live="polite"></div>
|
||||||
@@ -279,7 +271,6 @@
|
|||||||
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<!-- Sectiunea 4: Formate de coloane salvate (column_mappings) -->
|
<!-- Sectiunea 4: Formate de coloane salvate (column_mappings) -->
|
||||||
<!-- US-010: mutata pe pozitia 4 (dupa Reguli automate) -->
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="font-size:15px; margin:0 0 12px;">Formate de coloane salvate</h2>
|
<h2 style="font-size:15px; margin:0 0 12px;">Formate de coloane salvate</h2>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
{# US-002 (5.5): aceeasi grila standard ca tabelul Trimiteri (_submissions.html):
|
{# Aceeasi grila standard ca tabelul Trimiteri: cod in .pill, denumire ca text normal
|
||||||
.tablewrap > table, antet th standard (mostenit din base.html), cod in .pill,
|
(singura coloana care se poate rupe pe randuri inguste), empty-state in .empty. #}
|
||||||
denumire ca text normal (singura coloana care se poate rupe pe randuri inguste),
|
|
||||||
empty-state in .empty. Zero stiluri inline noi — totul vine din base.html. #}
|
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
<div class="tablewrap">
|
<div class="tablewrap">
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Rezumat stari (id stabil pentru OOB swap dupa editarea unui rand — US-002) -->
|
<!-- Rezumat stari (id stabil pentru OOB swap dupa editarea unui rand) -->
|
||||||
{% set status_labels = [
|
{% set status_labels = [
|
||||||
('ok', 'gata de trimis'),
|
('ok', 'gata de trimis'),
|
||||||
('needs_review', 'verifica valori'),
|
('needs_review', 'verifica valori'),
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Tabel preview. Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form,
|
<!-- Tabel preview. Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form,
|
||||||
altfel Enter intr-un camp ar declansa trimiterea ireversibila — D-3.3). Bifele
|
altfel Enter intr-un camp ar declansa trimiterea ireversibila). Bifele
|
||||||
needs_review se asociaza la #confirm-form prin atributul form=. -->
|
needs_review se asociaza la #confirm-form prin atributul form=. -->
|
||||||
<div class="tablewrap">
|
<div class="tablewrap">
|
||||||
<table>
|
<table>
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||||
<div class="sticky-bar">
|
<div class="sticky-bar">
|
||||||
<div style="flex:1; min-width:280px;">
|
<div style="flex:1; min-width:280px;">
|
||||||
<!-- Banner declarant (D12) — direct deasupra input-ului N -->
|
<!-- Banner declarant — direct deasupra input-ului N -->
|
||||||
<div class="banner warn" style="margin-bottom:10px; padding:8px 12px; border-radius:6px;"
|
<div class="banner warn" style="margin-bottom:10px; padding:8px 12px; border-radius:6px;"
|
||||||
role="note" aria-live="polite">
|
role="note" aria-live="polite">
|
||||||
Confirmand, TU esti declarantul acestor
|
Confirmand, TU esti declarantul acestor
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Contor "gata de trimis" citit din DOM (data-ok), ca OOB swap-ul de la editare
|
<!-- Contor "gata de trimis" citit din DOM (data-ok), ca OOB swap-ul de la editare
|
||||||
sa actualizeze N fara a re-randa sectiunea (US-002). -->
|
sa actualizeze N fara a re-randa sectiunea. -->
|
||||||
<span id="preview-ok-count" data-ok="{{ summary.get('ok', 0) }}" hidden></span>
|
<span id="preview-ok-count" data-ok="{{ summary.get('ok', 0) }}" hidden></span>
|
||||||
|
|
||||||
<div style="padding:8px 0 4px;">
|
<div style="padding:8px 0 4px;">
|
||||||
@@ -212,13 +212,13 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
/* D-1.2: un singur sticky bar pe ecran — cat preview-ul de import e activ,
|
/* Un singur sticky bar pe ecran — cat preview-ul de import e activ,
|
||||||
ascunde sectiunea Trimiteri de pe Acasa (se reveleaza la reset/commit din _upload.html). */
|
ascunde sectiunea Trimiteri de pe Acasa (se reveleaza la reset/commit din _upload.html). */
|
||||||
var trim = document.getElementById('trimiteri-section');
|
var trim = document.getElementById('trimiteri-section');
|
||||||
if (trim) trim.style.display = 'none';
|
if (trim) trim.style.display = 'none';
|
||||||
|
|
||||||
/* nOk se citeste din DOM (#preview-ok-count[data-ok]) ca OOB swap-ul de la editare
|
/* nOk se citeste din DOM (#preview-ok-count[data-ok]) ca OOB swap-ul de la editare
|
||||||
sa-l poata actualiza fara re-randarea sectiunii (D-3.1/D-3.4). */
|
sa-l poata actualiza fara re-randarea sectiunii. */
|
||||||
function getOk() {
|
function getOk() {
|
||||||
var el = document.getElementById('preview-ok-count');
|
var el = document.getElementById('preview-ok-count');
|
||||||
return el ? parseInt(el.dataset.ok || '0', 10) : 0;
|
return el ? parseInt(el.dataset.ok || '0', 10) : 0;
|
||||||
@@ -231,7 +231,7 @@
|
|||||||
var inp = document.getElementById('n-confirmat');
|
var inp = document.getElementById('n-confirmat');
|
||||||
var disp = document.getElementById('n-display');
|
var disp = document.getElementById('n-display');
|
||||||
var btn = document.getElementById('confirm-btn');
|
var btn = document.getElementById('confirm-btn');
|
||||||
/* Nu re-activa confirm cat un rand e in editare (mutual-exclusion D-3.2). */
|
/* Nu re-activa confirm cat un rand e in editare (mutual-exclusion). */
|
||||||
var editing = document.querySelector('tr[data-editing="1"]') !== null;
|
var editing = document.querySelector('tr[data-editing="1"]') !== null;
|
||||||
if (inp) inp.value = total;
|
if (inp) inp.value = total;
|
||||||
if (disp) disp.textContent = total;
|
if (disp) disp.textContent = total;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{#
|
{#
|
||||||
_preview_rand.html — un singur rand de preview import (US-002, 3.6).
|
_preview_rand.html — un singur rand de preview import.
|
||||||
Doua moduri:
|
Doua moduri:
|
||||||
- display (editing falsy): <tr> normal + buton "Editeaza" pe coloana de actiuni.
|
- display (editing falsy): <tr> normal + buton "Editeaza" pe coloana de actiuni.
|
||||||
- edit (editing truthy): <tr> cu un singur <td colspan> ce contine un FORM PROPRIU
|
- edit (editing truthy): <tr> cu un singur <td colspan> ce contine un FORM PROPRIU
|
||||||
(NU #confirm-form) cu grila responsiva refolosita din _trimitere_detaliu.html.
|
(NU #confirm-form) cu grila responsiva refolosita din _trimitere_detaliu.html.
|
||||||
Swap pe RAND (hx-target pe #preview-row-N, outerHTML), NU pe #import-section (D-3.1).
|
Swap pe RAND (hx-target pe #preview-row-N, outerHTML), NU pe #import-section.
|
||||||
La save, optional OOB pe rezumat + contor "gata de trimis" (include_oob).
|
La save, optional OOB pe rezumat + contor "gata de trimis" (include_oob).
|
||||||
#}
|
#}
|
||||||
{%- set res = row.resolved -%}
|
{%- set res = row.resolved -%}
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
/* Mutual-exclusion (D-3.2/3.6): cat un rand e in editare, dezactiveaza confirm + alte Editeaza. */
|
/* Mutual-exclusion: cat un rand e in editare, dezactiveaza confirm + alte Editeaza. */
|
||||||
var btn = document.getElementById('confirm-btn');
|
var btn = document.getElementById('confirm-btn');
|
||||||
if (btn) { btn.disabled = true; btn.title = 'Termina editarea randului inainte de a trimite.'; }
|
if (btn) { btn.disabled = true; btn.title = 'Termina editarea randului inainte de a trimite.'; }
|
||||||
document.querySelectorAll('.btn-editeaza').forEach(function(b) { b.disabled = true; });
|
document.querySelectorAll('.btn-editeaza').forEach(function(b) { b.disabled = true; });
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if include_oob %}
|
{% if include_oob %}
|
||||||
{# OOB: actualizeaza rezumatul si contorul "gata de trimis" dupa save, fara a re-randa sectiunea (D-3.1). #}
|
{# OOB: actualizeaza rezumatul si contorul "gata de trimis" dupa save, fara a re-randa sectiunea. #}
|
||||||
{% set status_labels = [
|
{% set status_labels = [
|
||||||
('ok','gata de trimis'), ('needs_review','verifica valori'), ('needs_mapping','fara cod RAR'),
|
('ok','gata de trimis'), ('needs_review','verifica valori'), ('needs_mapping','fara cod RAR'),
|
||||||
('needs_data','date lipsa'), ('already_sent','deja trimis'), ('duplicate_in_file','dublicat in fisier')] %}
|
('needs_data','date lipsa'), ('already_sent','deja trimis'), ('duplicate_in_file','dublicat in fisier')] %}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
OOB: actualizeaza inputul id="f-page" din #filtre-trimiteri (US-004 L2).
|
OOB: actualizeaza inputul id="f-page" din #filtre-trimiteri.
|
||||||
Poll-ul de 15s (hx-include="#filtre-trimiteri") preia automat pagina curenta.
|
Reincarcarea (hx-include="#filtre-trimiteri") preia automat pagina curenta.
|
||||||
Elementul OOB e extras din continutul normal de HTMX inainte de swap in #submissions-wrap.
|
Elementul OOB e extras din continutul normal de HTMX inainte de swap in #submissions-wrap.
|
||||||
#}
|
#}
|
||||||
<input type="hidden" id="f-page" name="page" value="{{ page | default(1) }}" hx-swap-oob="true">
|
<input type="hidden" id="f-page" name="page" value="{{ page | default(1) }}" hx-swap-oob="true">
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<span id="trimiteri-versiune" data-v="{{ versiune_trimiteri | default('') }}" hidden></span>
|
<span id="trimiteri-versiune" data-v="{{ versiune_trimiteri | default('') }}" hidden></span>
|
||||||
|
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
{# US-011: form de stergere bulk. Selectia opereaza DOAR pe randuri blocate
|
{# Form de stergere bulk. Selectia opereaza DOAR pe randuri blocate
|
||||||
(gestionabil); sent/sending/queued nu au checkbox (read-only). #}
|
(gestionabil); sent/sending/queued nu au checkbox (read-only). #}
|
||||||
<form id="bulk-trimiteri"
|
<form id="bulk-trimiteri"
|
||||||
hx-post="/trimiteri/sterge-bulk"
|
hx-post="/trimiteri/sterge-bulk"
|
||||||
@@ -43,9 +43,8 @@
|
|||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for r in rows %}
|
{% for r in rows %}
|
||||||
{# PRD 5.9 US-003: randul declanseaza deschiderea MODALULUI global (#detaliu-modal-body),
|
{# Randul declanseaza deschiderea MODALULUI global (#detaliu-modal-body).
|
||||||
nu un rand-sibling. Clickabil/focusabil (role=button); Enter/Space deschid modalul
|
Clickabil/focusabil (role=button); Enter/Space deschid modalul (JS in base.html). #}
|
||||||
(JS in base.html). Vechiul rand-sibling de detaliu a fost eliminat. #}
|
|
||||||
<tr id="trimitere-row-{{ r.id }}"
|
<tr id="trimitere-row-{{ r.id }}"
|
||||||
class="trimitere-row"
|
class="trimitere-row"
|
||||||
data-detaliu-id="{{ r.id }}"
|
data-detaliu-id="{{ r.id }}"
|
||||||
@@ -65,8 +64,8 @@
|
|||||||
<td class="col-id muted" data-eticheta="#">{{ r.id }}</td>
|
<td class="col-id muted" data-eticheta="#">{{ r.id }}</td>
|
||||||
<td class="col-stare" data-eticheta="Stare">
|
<td class="col-stare" data-eticheta="Stare">
|
||||||
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span>
|
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span>
|
||||||
{# PRD 5.9 US-002 (R1): eticheta umana scurta sub pill — text mic, `s-error`
|
{# Eticheta umana scurta sub pill — text mic, `s-error` pe error/needs_*
|
||||||
pe error/needs_* (singurele stari pe care `eticheta_problema` e ne-goala).
|
(singurele stari pe care `eticheta_problema` e ne-goala).
|
||||||
Stare transmisa prin TEXT, nu doar culoare. Codul brut ramane in modal. #}
|
Stare transmisa prin TEXT, nu doar culoare. Codul brut ramane in modal. #}
|
||||||
{% if r.eticheta_problema %}
|
{% if r.eticheta_problema %}
|
||||||
<div class="eticheta-problema s-error">{{ r.eticheta_problema }}</div>
|
<div class="eticheta-problema s-error">{{ r.eticheta_problema }}</div>
|
||||||
@@ -75,14 +74,14 @@
|
|||||||
<td class="col-vehicul" data-eticheta="Vehicul">
|
<td class="col-vehicul" data-eticheta="Vehicul">
|
||||||
{{ r.prez.vehicul_nr }}
|
{{ r.prez.vehicul_nr }}
|
||||||
{% if r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
|
{% if r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
|
||||||
{# US-005: VIN pe rand separat sub nr (element block, nu span inline) #}
|
{# VIN pe rand separat sub nr (element block, nu span inline) #}
|
||||||
<div class="muted" style="font-size:12px;">{{ r.prez.vin_scurt }}</div>
|
<div class="muted" style="font-size:12px;">{{ r.prez.vin_scurt }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-operatie" data-eticheta="Operatie">
|
<td class="col-operatie" data-eticheta="Operatie">
|
||||||
<div>{{ r.prez.operatie }}</div>
|
<div>{{ r.prez.operatie }}</div>
|
||||||
{# PRD 5.9 US-002: doar codul RAR (ex. OE-2), FARA prefixul "cod RAR:" — chip
|
{# Doar codul RAR (ex. OE-2), FARA prefixul "cod RAR:" — chip muted discret;
|
||||||
muted discret; cand nemapat afiseaza "nemapat" muted (comportament 5.8). #}
|
cand nemapat afiseaza "nemapat" muted. #}
|
||||||
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %}
|
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %}
|
||||||
<div class="cod-rar-sub"><span class="cod-rar-cod">{{ r.prez.cod_rar }}</span></div>
|
<div class="cod-rar-sub"><span class="cod-rar-cod">{{ r.prez.cod_rar }}</span></div>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -100,7 +99,7 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{#
|
{#
|
||||||
Paginare numerotata (US-004 PRD 5.10).
|
Paginare numerotata.
|
||||||
Afisata doar cand exista mai mult de o pagina.
|
Afisata doar cand exista mai mult de o pagina.
|
||||||
Fiecare link pastreaza filtrele curente (status, vehicul, data_de, data_pana).
|
Fiecare link pastreaza filtrele curente (status, vehicul, data_de, data_pana).
|
||||||
Pagina curenta: aria-current="page" (semantic).
|
Pagina curenta: aria-current="page" (semantic).
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
{% from "_eroare.html" import card_erori %}
|
{% from "_eroare.html" import card_erori %}
|
||||||
{% import '_macros.html' as ui %}
|
{% import '_macros.html' as ui %}
|
||||||
{# PRD 5.9 US-004: detaliu editabil in-place, butoane consolidate, ordine verticala R10.
|
{# Detaliu editabil in-place. Fragmentul se swap-uieste in corpul modalului global
|
||||||
Fragmentul se swap-uieste in corpul modalului global (#detaliu-modal-body). Heading-ul
|
(#detaliu-modal-body). Heading-ul poarta id-ul folosit de aria-labelledby al dialogului.
|
||||||
poarta id-ul folosit de aria-labelledby al dialogului.
|
Operatie + cod RAR rezolvat apar IMPREUNA, read-only, folosind `prez.cod_rar`
|
||||||
R9: operatie + cod RAR rezolvat apar IMPREUNA, read-only, folosind `prez.cod_rar`
|
|
||||||
(fallback „nemapat"), fara eticheta separata „Cod RAR". #}
|
(fallback „nemapat"), fara eticheta separata „Cod RAR". #}
|
||||||
{% set cod_afis = prez.cod_rar if (prez.cod_rar and prez.cod_rar != '—') else 'nemapat' %}
|
{% set cod_afis = prez.cod_rar if (prez.cod_rar and prez.cod_rar != '—') else 'nemapat' %}
|
||||||
<div class="card" id="detaliu-card-{{ id }}" style="border:none; padding:0; margin:0;">
|
<div class="card" id="detaliu-card-{{ id }}" style="border:none; padding:0; margin:0;">
|
||||||
|
|
||||||
{# === R10 (1): header — #id + pill + motiv uman === #}
|
{# === Header — #id + pill + motiv uman === #}
|
||||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 8px;">
|
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 8px;">
|
||||||
<h2 id="detaliu-modal-titlu" style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
|
<h2 id="detaliu-modal-titlu" style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
|
||||||
<span class="pill {{ stare_css }}">{{ stare_text }}</span>
|
<span class="pill {{ stare_css }}">{{ stare_text }}</span>
|
||||||
@@ -19,7 +18,7 @@
|
|||||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">{{ stare_subtext }}</p>
|
<p class="muted" style="margin:0 0 12px; font-size:13px;">{{ stare_subtext }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# === R10 (2): bloc eroare blocanta — DOAR in read-only (US-008).
|
{# === Bloc eroare blocanta — DOAR in read-only.
|
||||||
In editare, cardul 3-niveluri e inlocuit cu: erori per-camp in macro `camp`
|
In editare, cardul 3-niveluri e inlocuit cu: erori per-camp in macro `camp`
|
||||||
(text simplu .s-error) + rezumat top-of-form pentru erori fara camp (mai jos). === #}
|
(text simplu .s-error) + rezumat top-of-form pentru erori fara camp (mai jos). === #}
|
||||||
{% if not editabil and erori_3n %}
|
{% if not editabil and erori_3n %}
|
||||||
@@ -28,7 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# === R10 (3) + R9: mapare inline (PRD 5.7) — alege cod RAR pentru operatiile nemapate.
|
{# === Mapare inline — alege cod RAR pentru operatiile nemapate.
|
||||||
Cand nemapate_inline, linia „Operatie: X · nemapat" apare in formularul de mai jos
|
Cand nemapate_inline, linia „Operatie: X · nemapat" apare in formularul de mai jos
|
||||||
(cod_afis = nemapat), iar aici e picker-ul; dupa mapare, re-render arata codul rezolvat. === #}
|
(cod_afis = nemapat), iar aici e picker-ul; dupa mapare, re-render arata codul rezolvat. === #}
|
||||||
{% if nemapate_inline %}
|
{% if nemapate_inline %}
|
||||||
@@ -78,7 +77,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# === R10 (4): formular editabil (needs_data/needs_mapping) SAU context read-only.
|
{# === Formular editabil (needs_data/needs_mapping) SAU context read-only.
|
||||||
Zero dublare: campurile vehiculului apar O SINGURA DATA — editabile cand randul e
|
Zero dublare: campurile vehiculului apar O SINGURA DATA — editabile cand randul e
|
||||||
corectabil, altfel read-only. Operatie + cod RAR read-only deasupra campurilor. === #}
|
corectabil, altfel read-only. Operatie + cod RAR read-only deasupra campurilor. === #}
|
||||||
{% if editabil %}
|
{% if editabil %}
|
||||||
@@ -90,7 +89,7 @@
|
|||||||
{% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div>
|
{% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# US-008 (M6): erori fara camp (field None) nu dispar silentios in editare —
|
{# Erori fara camp (field None) nu dispar silentios in editare —
|
||||||
cardul 3n e ascuns, deci adaugam un rezumat simplu top-of-form.
|
cardul 3n e ascuns, deci adaugam un rezumat simplu top-of-form.
|
||||||
Erori cu camp raman afisate per-camp de macro-ul `camp` de mai jos. #}
|
Erori cu camp raman afisate per-camp de macro-ul `camp` de mai jos. #}
|
||||||
{% for e in erori_3n if not e.field %}
|
{% for e in erori_3n if not e.field %}
|
||||||
@@ -114,7 +113,7 @@
|
|||||||
hx-disabled-elt="find button">
|
hx-disabled-elt="find button">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
{# US-006: select cod RAR pe stari editabile (needs_data/needs_mapping), cu nomenclator.
|
{# Select cod RAR pe stari editabile (needs_data/needs_mapping), cu nomenclator.
|
||||||
Read-only pe sent/sending/queued/error (nomenclator_rar gol → ramura else). #}
|
Read-only pe sent/sending/queued/error (nomenclator_rar gol → ramura else). #}
|
||||||
{% if nomenclator_rar %}
|
{% if nomenclator_rar %}
|
||||||
<div style="margin:0 0 12px;">
|
<div style="margin:0 0 12px;">
|
||||||
@@ -133,15 +132,15 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Operatie + cod RAR read-only deasupra campurilor (R9, fara eticheta „Cod RAR"). #}
|
{# Operatie + cod RAR read-only deasupra campurilor (fara eticheta „Cod RAR"). #}
|
||||||
<div style="margin:0 0 12px;">
|
<div style="margin:0 0 12px;">
|
||||||
<div class="muted" style="font-size:12px;">Operatie</div>
|
<div class="muted" style="font-size:12px;">Operatie</div>
|
||||||
<div>{{ prez.operatie }} · {{ cod_afis }}</div>
|
<div>{{ prez.operatie }} · {{ cod_afis }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# US-007: operatie service (cod intern + denumire venita prin API/import), distinct de
|
{# Operatie service (cod intern + denumire venita prin API/import), distinct de
|
||||||
operatia RAR mapata. Conventie US-002: op_service_cod="" cand lipseste → randul absent. #}
|
operatia RAR mapata. op_service_cod="" cand lipseste → randul absent. #}
|
||||||
{% if prez.op_service_cod %}
|
{% if prez.op_service_cod %}
|
||||||
<div style="margin:0 0 12px;">
|
<div style="margin:0 0 12px;">
|
||||||
<div class="muted" style="font-size:12px;">Operatie service</div>
|
<div class="muted" style="font-size:12px;">Operatie service</div>
|
||||||
@@ -160,7 +159,7 @@
|
|||||||
{{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial) }}
|
{{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# === R10 (5): actiune primara conditionata de stare (R2). needs_data/needs_mapping
|
{# === Actiune primara conditionata de stare. needs_data/needs_mapping
|
||||||
-> „Salveaza si retrimite" pe /corecteaza. UN SINGUR buton primar per stare. === #}
|
-> „Salveaza si retrimite" pe /corecteaza. UN SINGUR buton primar per stare. === #}
|
||||||
<div style="margin-top:14px;">
|
<div style="margin-top:14px;">
|
||||||
<button type="submit">Salveaza si retrimite</button>
|
<button type="submit">Salveaza si retrimite</button>
|
||||||
@@ -177,8 +176,8 @@
|
|||||||
<div style="word-break:break-all;">{{ prez.vin }}</div>
|
<div style="word-break:break-all;">{{ prez.vin }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div><div class="muted" style="font-size:12px;">Operatie</div><div>{{ prez.operatie }} · {{ cod_afis }}</div></div>
|
<div><div class="muted" style="font-size:12px;">Operatie</div><div>{{ prez.operatie }} · {{ cod_afis }}</div></div>
|
||||||
{# US-007: operatie service (cod intern + denumire), distinct de operatia RAR.
|
{# Operatie service (cod intern + denumire), distinct de operatia RAR.
|
||||||
Conventie US-002: op_service_cod="" cand lipseste → randul absent (fara "—"). #}
|
op_service_cod="" cand lipseste → randul absent (fara "—"). #}
|
||||||
{% if prez.op_service_cod %}
|
{% if prez.op_service_cod %}
|
||||||
<div><div class="muted" style="font-size:12px;">Operatie service</div>
|
<div><div class="muted" style="font-size:12px;">Operatie service</div>
|
||||||
<div>{{ prez.op_service_cod }}{% if prez.op_service_denumire %} — {{ prez.op_service_denumire }}{% endif %}</div></div>
|
<div>{{ prez.op_service_cod }}{% if prez.op_service_denumire %} — {{ prez.op_service_denumire }}{% endif %}</div></div>
|
||||||
@@ -188,17 +187,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# === R10 (5): actiuni de jos — primar Re-pune (doar error) + Sterge pe RAND SEPARAT (R2/R11) === #}
|
{# === Actiuni de jos — primar Re-pune (doar error) + Sterge pe RAND SEPARAT === #}
|
||||||
{% if status == 'error' or gestionabil %}
|
{% if status == 'error' or gestionabil %}
|
||||||
<div class="detaliu-actiuni-jos" style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);">
|
<div class="detaliu-actiuni-jos" style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);">
|
||||||
{# R2: error -> buton primar „Re-pune in coada" pe /repune (error nu e editabil pentru #}
|
{# Error -> buton primar „Re-pune in coada" pe /repune (error nu e editabil pentru #}
|
||||||
{# campuri vehicul, dar US-006b permite schimbarea cod_prestatie prin acelasi formular). #}
|
{# campuri vehicul, dar se poate schimba cod_prestatie prin acelasi formular). #}
|
||||||
{% if status == 'error' %}
|
{% if status == 'error' %}
|
||||||
<form hx-post="/trimitere/{{ id }}/repune"
|
<form hx-post="/trimitere/{{ id }}/repune"
|
||||||
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
|
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
|
||||||
hx-disabled-elt="find button" style="margin:0 0 10px;">
|
hx-disabled-elt="find button" style="margin:0 0 10px;">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
{# US-006b: select cod_prestatie optional in formularul /repune (doar pentru error). #}
|
{# Select cod_prestatie optional in formularul /repune (doar pentru error). #}
|
||||||
{% if nomenclator_rar %}
|
{% if nomenclator_rar %}
|
||||||
<label for="cod-rar-error-{{ id }}" style="display:block; font-size:12px; color:var(--muted); margin-bottom:4px;">
|
<label for="cod-rar-error-{{ id }}" style="display:block; font-size:12px; color:var(--muted); margin-bottom:4px;">
|
||||||
Operatie RAR (optional — schimba codul si re-pune)
|
Operatie RAR (optional — schimba codul si re-pune)
|
||||||
@@ -219,7 +218,7 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# R11: UN SINGUR Sterge, outline distructiv (var(--err)), pe rand separat, full-width pe mobil. #}
|
{# UN SINGUR Sterge, outline distructiv (var(--err)), pe rand separat, full-width pe mobil. #}
|
||||||
{% if gestionabil %}
|
{% if gestionabil %}
|
||||||
<form hx-post="/trimitere/{{ id }}/sterge"
|
<form hx-post="/trimitere/{{ id }}/sterge"
|
||||||
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
|
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
|
||||||
@@ -235,7 +234,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# === R10 (6): Detalii tehnice — colapsat implicit === #}
|
{# === Detalii tehnice — colapsat implicit === #}
|
||||||
<details style="margin-top:14px;">
|
<details style="margin-top:14px;">
|
||||||
<summary class="muted" style="font-size:12px; cursor:pointer;">Detalii tehnice</summary>
|
<summary class="muted" style="font-size:12px; cursor:pointer;">Detalii tehnice</summary>
|
||||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:10px 24px; margin-top:10px;">
|
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:10px 24px; margin-top:10px;">
|
||||||
@@ -257,7 +256,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
{# PRD 5.9 US-004 (R4): scriptul inline vechi (marcheazaDetaliuDeschis / scrollIntoView pe
|
{# Focus-ul post-swap (incl. re-render corectie/mapare) e gestionat de htmx:afterSettle pe
|
||||||
randul-sibling) a fost eliminat de US-003. Focus-ul post-swap (incl. re-render corectie/
|
#detaliu-modal-body din base.html. Inchiderea modalului pe succes (queued/sterge) vine
|
||||||
mapare) e gestionat de htmx:afterSettle pe #detaliu-modal-body din base.html. R5: inchiderea
|
din HX-Trigger `inchideModal` emis de rute. #}
|
||||||
modalului pe succes (queued/sterge) vine din HX-Trigger `inchideModal` emis de rute. #}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div id="import-section">
|
<div id="import-section">
|
||||||
{% set pas = 1 %}{% include '_stepper.html' %}
|
{% set pas = 1 %}{% include '_stepper.html' %}
|
||||||
{# US-004 (3.6): bara de upload accentuata (border de accent) ca sa ramana punctul
|
{# Bara de upload accentuata (border de accent) ca sa ramana punctul
|
||||||
de intrare evident chiar cu tabelul Trimiteri lung dedesubt (D-1.1/D-5.2). #}
|
de intrare evident chiar cu tabelul Trimiteri lung dedesubt. #}
|
||||||
{% from '_eroare.html' import card_erori %}
|
{% from '_eroare.html' import card_erori %}
|
||||||
<div class="card" style="border-color:var(--accent);">
|
<div class="card" style="border-color:var(--accent);">
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
var dz = document.getElementById('drop-zone');
|
var dz = document.getElementById('drop-zone');
|
||||||
var frm = document.getElementById('upload-form');
|
var frm = document.getElementById('upload-form');
|
||||||
|
|
||||||
/* US-003 (3.6): un singur sticky bar pe ecran — cand re-apare zona de upload
|
/* Un singur sticky bar pe ecran — cand re-apare zona de upload
|
||||||
(reset sau dupa commit), sectiunea Trimiteri redevine vizibila. */
|
(reset sau dupa commit), sectiunea Trimiteri redevine vizibila. */
|
||||||
var trim = document.getElementById('trimiteri-section');
|
var trim = document.getElementById('trimiteri-section');
|
||||||
if (trim) trim.style.display = '';
|
if (trim) trim.style.display = '';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% block title %}Conturi clienti — Gateway RAR AUTOPASS{% endblock %}
|
{% block title %}Conturi clienti — Gateway RAR AUTOPASS{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{# US-009 (5.5): metadate verbe de ciclu de viata (eticheta, ruta, clasa). #}
|
{# Metadate verbe de ciclu de viata (eticheta, ruta, clasa). #}
|
||||||
{% set VERBS = {
|
{% set VERBS = {
|
||||||
'activate': ('Activeaza', '/admin/activate', ''),
|
'activate': ('Activeaza', '/admin/activate', ''),
|
||||||
'block': ('Blocheaza', '/admin/block', ''),
|
'block': ('Blocheaza', '/admin/block', ''),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>{% block title %}Gateway RAR AUTOPASS{% endblock %}</title>
|
<title>{% block title %}Gateway RAR AUTOPASS{% endblock %}</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// US-002 (3.6): raspunsurile de editare-rand contin un <tr> (swap pe rand) PLUS
|
// Raspunsurile de editare-rand contin un <tr> (swap pe rand) PLUS
|
||||||
// elemente OOB non-rand (#preview-rezumat, #preview-ok-count). Fara fragmente-template,
|
// elemente OOB non-rand (#preview-rezumat, #preview-ok-count). Fara fragmente-template,
|
||||||
// htmx parseaza raspunsul care incepe cu <tr> in context de tabel (<table><tbody>) si
|
// htmx parseaza raspunsul care incepe cu <tr> in context de tabel (<table><tbody>) si
|
||||||
// "foster-parent"-eaza div/span-urile OOB afara din fragment -> swapError + contoare pierdute.
|
// "foster-parent"-eaza div/span-urile OOB afara din fragment -> swapError + contoare pierdute.
|
||||||
@@ -14,8 +14,8 @@
|
|||||||
htmx.config.useTemplateFragments = true;
|
htmx.config.useTemplateFragments = true;
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// Anti-FOUC (US-001 PRD 5.3, extins US-014 PRD 5.10): citeste preferinta tema din
|
// Anti-FOUC: citeste preferinta tema din localStorage inainte de primul
|
||||||
// localStorage inainte de primul paint; seteaza data-theme pe <html> sincron, fara blink.
|
// paint; seteaza data-theme pe <html> sincron, fara blink.
|
||||||
// Cunoaste toate cele 4 teme: light/dark/petrol/auto. Valoare legacy/necunoscuta -> auto.
|
// Cunoaste toate cele 4 teme: light/dark/petrol/auto. Valoare legacy/necunoscuta -> auto.
|
||||||
// 'auto' se rezolva la 'light' sau 'dark' dupa prefers-color-scheme (fara blink).
|
// 'auto' se rezolva la 'light' sau 'dark' dupa prefers-color-scheme (fara blink).
|
||||||
(function() {
|
(function() {
|
||||||
@@ -33,12 +33,9 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
/* US-013 (PRD 5.10): IBM Plex Sans + Mono self-hosted (latin-ext pentru diacritice romanesti).
|
/* IBM Plex Sans + Mono self-hosted (latin-ext pentru diacritice romanesti).
|
||||||
font-display:swap permite text vizibil inainte de incarcare (FOUT system-ui->IBM Plex).
|
font-display:swap permite text vizibil inainte de incarcare (FOUT system-ui->IBM Plex);
|
||||||
FOUT pe tabular-nums: IBM Plex Sans are metrici apropiate de system-ui; reflow-ul vizibil
|
reflow-ul vizibil pe VIN/coduri e acceptat explicit. */
|
||||||
pe VIN/coduri e acceptat explicit — fontul se incarca din /static/ (acelasi origin).
|
|
||||||
IBM Plex Sans/Mono self-host, subset latin + latin-ext de pe fontsource
|
|
||||||
(@fontsource/ibm-plex-sans + @fontsource/ibm-plex-mono, v5.0.8), woff2 valide. */
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "IBM Plex Sans";
|
font-family: "IBM Plex Sans";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -103,38 +100,32 @@
|
|||||||
src: url("/static/fonts/IBMPlexMono-Regular-latin.woff2") format("woff2");
|
src: url("/static/fonts/IBMPlexMono-Regular-latin.woff2") format("woff2");
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
/* Paleta dark (default) — accent azur ROMFAST conform DESIGN.md */
|
/* Paleta dark (default) — accent azur ROMFAST */
|
||||||
:root { --bg:#0f1218; --card:#181c24; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36;
|
:root { --bg:#0f1218; --card:#181c24; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36;
|
||||||
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6; }
|
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6; }
|
||||||
/* Paleta light — accent azur inchis pentru contrast AA pe alb (#1F66C9: 5.51:1 pe alb) */
|
/* Paleta light — accent azur inchis pentru contrast AA pe alb (#1F66C9: 5.51:1 pe alb) */
|
||||||
[data-theme="light"] { --bg:#f5f7fa; --card:#ffffff; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea;
|
[data-theme="light"] { --bg:#f5f7fa; --card:#ffffff; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea;
|
||||||
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#1F66C9; }
|
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#1F66C9; }
|
||||||
/* Paleta Petrol (US-014) — tema intunecata alternativa, accent teal #0E7C7B.
|
/* Paleta Petrol — tema intunecata alternativa, accent teal #0E7C7B.
|
||||||
Wordmark-ul FAST #2E74D6 coexista armonios: ambele sunt reci/saturate, contrast AA pe --card #161e20. */
|
Wordmark-ul FAST #2E74D6 coexista armonios: ambele sunt reci/saturate, contrast AA pe --card #161e20. */
|
||||||
[data-theme="petrol"] { --bg:#0e1416; --card:#161e20; --ink:#e6e9ef; --muted:#8b93a7; --line:#232c2e;
|
[data-theme="petrol"] { --bg:#0e1416; --card:#161e20; --ink:#e6e9ef; --muted:#8b93a7; --line:#232c2e;
|
||||||
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#0E7C7B; }
|
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#0E7C7B; }
|
||||||
* { box-sizing:border-box; }
|
* { box-sizing:border-box; }
|
||||||
/* PRD 5.9 US-006 — CONVENTIE BREAKPOINT: un singur prag mobil la 768px.
|
/* CONVENTIE BREAKPOINT: un singur prag mobil la 768px.
|
||||||
CSS custom properties NU functioneaza in `@media`, deci pragul nu poate fi o
|
CSS custom properties NU functioneaza in `@media`, deci pragul nu poate fi o
|
||||||
variabila; folosim consecvent `@media (max-width:767px)` peste tot (mobil) si
|
variabila; folosim consecvent `@media (max-width:767px)` peste tot (mobil) si
|
||||||
`@media (max-width:1024px)` doar pentru densitatea tabelului. >=1024px = layout
|
`@media (max-width:1024px)` doar pentru densitatea tabelului. >=1024px = layout
|
||||||
desktop neschimbat (fara regresie). Orice regula mobila noua reutilizeaza 767px. */
|
desktop neschimbat (fara regresie). Orice regula mobila noua reutilizeaza 767px. */
|
||||||
body { margin:0; font:15px/1.5 "IBM Plex Sans",system-ui,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
body { margin:0; font:15px/1.5 "IBM Plex Sans",system-ui,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||||
background:var(--bg); color:var(--ink); -webkit-font-smoothing:antialiased; }
|
background:var(--bg); color:var(--ink); -webkit-font-smoothing:antialiased; }
|
||||||
/* US-012c (PRD 5.10): grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). */
|
/* Grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). */
|
||||||
header { padding:16px 24px; border-bottom:1px solid var(--line);
|
header { padding:16px 24px; border-bottom:1px solid var(--line);
|
||||||
display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:8px; min-height:92px; }
|
display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:8px; min-height:92px; }
|
||||||
.header-left { display:flex; align-items:center; }
|
.header-left { display:flex; align-items:center; }
|
||||||
.header-center { display:flex; flex-direction:column; align-items:center; text-align:center; }
|
.header-center { display:flex; flex-direction:column; align-items:center; text-align:center; }
|
||||||
.header-right { display:flex; align-items:center; justify-content:flex-end; gap:8px; }
|
.header-right { display:flex; align-items:center; justify-content:flex-end; gap:8px; }
|
||||||
/* US-012c: logo PNG ROMFAST in header-left (brand top-left ca pe romfast.ro).
|
/* Logo ROMFAST aliniat stanga; transparent, ok pe dark/light/petrol fara filtre de culoare. */
|
||||||
32px inaltime — usor mai mare decat in header-center (28px) pentru vizibilitate ca brand anchor.
|
|
||||||
margin:0 — aliniat stanga, NU centrat (era `margin:3px auto 0` cand era sub titlu).
|
|
||||||
Logo transparent: ok pe dark/light/petrol fara filtre de culoare. */
|
|
||||||
/* Logo ROMFAST la dimensiunea de pe romfast.ro (~60px inaltime), aliniat stanga. */
|
|
||||||
.brand-logo { height:60px; width:auto; display:block; margin:0; }
|
.brand-logo { height:60px; width:auto; display:block; margin:0; }
|
||||||
/* Env badge mic sub titlu in header-center (US-012c): nu mai echilibreaza optic dreapta
|
|
||||||
(logo-ul face asta), ci identifica mediul langa titlu. Pastrat mic, color:var(--muted). */
|
|
||||||
.header-center .env { font-size:11px; margin-top:2px; }
|
.header-center .env { font-size:11px; margin-top:2px; }
|
||||||
header h1 { font-size:20px; margin:0; font-weight:700; letter-spacing:-.01em; }
|
header h1 { font-size:20px; margin:0; font-weight:700; letter-spacing:-.01em; }
|
||||||
header .env { font-size:12px; color:var(--muted); border:1px solid var(--line); padding:2px 8px; border-radius:99px; }
|
header .env { font-size:12px; color:var(--muted); border:1px solid var(--line); padding:2px 8px; border-radius:99px; }
|
||||||
@@ -210,7 +201,7 @@
|
|||||||
button { background:var(--accent); border-color:var(--accent); color:#fff; cursor:pointer; }
|
button { background:var(--accent); border-color:var(--accent); color:#fff; cursor:pointer; }
|
||||||
button:hover { filter:brightness(1.08); }
|
button:hover { filter:brightness(1.08); }
|
||||||
.chk { font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px; }
|
.chk { font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px; }
|
||||||
/* Tab-bar (US-003) */
|
/* Tab-bar */
|
||||||
.tab-bar { display:flex; gap:2px; overflow-x:auto; -webkit-overflow-scrolling:touch;
|
.tab-bar { display:flex; gap:2px; overflow-x:auto; -webkit-overflow-scrolling:touch;
|
||||||
border-bottom:1px solid var(--line); margin-bottom:16px; padding-bottom:0;
|
border-bottom:1px solid var(--line); margin-bottom:16px; padding-bottom:0;
|
||||||
scrollbar-width:none; }
|
scrollbar-width:none; }
|
||||||
@@ -224,7 +215,7 @@
|
|||||||
border-color:var(--line); border-bottom-color:var(--card); }
|
border-color:var(--line); border-bottom-color:var(--card); }
|
||||||
.tab-panel { min-height:120px; }
|
.tab-panel { min-height:120px; }
|
||||||
.status-bar { margin-bottom:12px; }
|
.status-bar { margin-bottom:12px; }
|
||||||
/* Eroare 3 niveluri (US-006, PRD 5.4) */
|
/* Eroare 3 niveluri */
|
||||||
.eroare-3n { margin-top:10px; }
|
.eroare-3n { margin-top:10px; }
|
||||||
.eroare-3n-item { padding:8px 10px; border-left:3px solid var(--err);
|
.eroare-3n-item { padding:8px 10px; border-left:3px solid var(--err);
|
||||||
background:color-mix(in srgb, var(--err) 8%, var(--card));
|
background:color-mix(in srgb, var(--err) 8%, var(--card));
|
||||||
@@ -237,13 +228,13 @@
|
|||||||
.eroare-3n-label { font-weight:500; }
|
.eroare-3n-label { font-weight:500; }
|
||||||
/* Inline fix per camp in preview */
|
/* Inline fix per camp in preview */
|
||||||
.camp-fix { color:var(--accent); font-size:11px; margin-top:2px; display:block; }
|
.camp-fix { color:var(--accent); font-size:11px; margin-top:2px; display:block; }
|
||||||
/* Meniu hamburger cont (US-006 PRD 5.5) — dropdown ancorat dreapta-sus */
|
/* Meniu hamburger cont — dropdown ancorat dreapta-sus */
|
||||||
.cont-menu-wrap { position:relative; }
|
.cont-menu-wrap { position:relative; }
|
||||||
.icon-btn { background:transparent; border:1px solid var(--line); color:var(--ink); cursor:pointer;
|
.icon-btn { background:transparent; border:1px solid var(--line); color:var(--ink); cursor:pointer;
|
||||||
border-radius:6px; min-height:36px; min-width:36px; font-size:16px; padding:4px 8px;
|
border-radius:6px; min-height:36px; min-width:36px; font-size:16px; padding:4px 8px;
|
||||||
line-height:1; display:inline-flex; align-items:center; justify-content:center; }
|
line-height:1; display:inline-flex; align-items:center; justify-content:center; }
|
||||||
.icon-btn:hover { background:var(--line); }
|
.icon-btn:hover { background:var(--line); }
|
||||||
/* US-011: variante icon-btn — dirty (modificari nesalvate) + danger (destructiv) */
|
/* Variante icon-btn — dirty (modificari nesalvate) + danger (destructiv) */
|
||||||
.icon-btn.dirty { background:var(--accent); color:#fff; border-color:var(--accent); }
|
.icon-btn.dirty { background:var(--accent); color:#fff; border-color:var(--accent); }
|
||||||
.icon-btn.dirty:hover { filter:brightness(0.9); }
|
.icon-btn.dirty:hover { filter:brightness(0.9); }
|
||||||
.icon-btn.danger { color:var(--err); border-color:var(--err); }
|
.icon-btn.danger { color:var(--err); border-color:var(--err); }
|
||||||
@@ -260,7 +251,7 @@
|
|||||||
.cont-menu form { margin:0; }
|
.cont-menu form { margin:0; }
|
||||||
/* Kebab partajat (actiuni per-rand in tabele). Meniul e position:fixed si pozitionat de JS:
|
/* Kebab partajat (actiuni per-rand in tabele). Meniul e position:fixed si pozitionat de JS:
|
||||||
altfel `.tablewrap { overflow-x:auto }` induce overflow-y:auto si TAIE dropdown-ul pe ultimul
|
altfel `.tablewrap { overflow-x:auto }` induce overflow-y:auto si TAIE dropdown-ul pe ultimul
|
||||||
rand (bug 5.5 — meniul nu se vedea). fixed scoate meniul din contextul de clipping al tabelului. */
|
rand. fixed scoate meniul din contextul de clipping al tabelului. */
|
||||||
.kebab { position:relative; display:inline-block; }
|
.kebab { position:relative; display:inline-block; }
|
||||||
.kebab > summary { list-style:none; cursor:pointer; display:inline-flex; align-items:center;
|
.kebab > summary { list-style:none; cursor:pointer; display:inline-flex; align-items:center;
|
||||||
justify-content:center; min-height:32px; min-width:32px; padding:4px 10px;
|
justify-content:center; min-height:32px; min-width:32px; padding:4px 10px;
|
||||||
@@ -288,7 +279,7 @@
|
|||||||
.dt-pager button { background:transparent; color:var(--ink); border:1px solid var(--line);
|
.dt-pager button { background:transparent; color:var(--ink); border:1px solid var(--line);
|
||||||
padding:5px 12px; min-height:32px; }
|
padding:5px 12px; min-height:32px; }
|
||||||
.dt-pager button:disabled { opacity:.45; cursor:default; }
|
.dt-pager button:disabled { opacity:.45; cursor:default; }
|
||||||
/* === Tabel trimiteri (PRD 5.8 US-007): fara scroll orizontal. SCOPAT prin
|
/* === Tabel trimiteri: fara scroll orizontal. SCOPAT prin
|
||||||
.tabel-trimiteri ca sa NU strice celelalte tabele (.tablewrap e partajat de
|
.tabel-trimiteri ca sa NU strice celelalte tabele (.tablewrap e partajat de
|
||||||
Mapari/Formate). Permitem wrap controlat pe coloanele text + latimi rezonabile. === */
|
Mapari/Formate). Permitem wrap controlat pe coloanele text + latimi rezonabile. === */
|
||||||
.tabel-trimiteri table { table-layout:fixed; }
|
.tabel-trimiteri table { table-layout:fixed; }
|
||||||
@@ -302,16 +293,15 @@
|
|||||||
.tabel-trimiteri .col-operatie > div { line-height:1.35; }
|
.tabel-trimiteri .col-operatie > div { line-height:1.35; }
|
||||||
/* secundarul muted („cod RAR" / „nemapat") — >=12px, contrast pe var(--muted) >=4.5:1 */
|
/* secundarul muted („cod RAR" / „nemapat") — >=12px, contrast pe var(--muted) >=4.5:1 */
|
||||||
.tabel-trimiteri .cod-rar-sub { font-size:12px; margin-top:2px; }
|
.tabel-trimiteri .cod-rar-sub { font-size:12px; margin-top:2px; }
|
||||||
/* PRD 5.9 US-002: codul RAR pe linia 2 — chip discret, fara prefixul „cod RAR:". */
|
/* Codul RAR pe linia 2 — chip discret, fara prefixul „cod RAR:". */
|
||||||
.tabel-trimiteri .cod-rar-cod { display:inline-block; font-family:"IBM Plex Mono",ui-monospace,monospace;
|
.tabel-trimiteri .cod-rar-cod { display:inline-block; font-family:"IBM Plex Mono",ui-monospace,monospace;
|
||||||
font-size:12px; padding:1px 7px; border:1px solid var(--line);
|
font-size:12px; padding:1px 7px; border:1px solid var(--line);
|
||||||
border-radius:99px; color:var(--muted); }
|
border-radius:99px; color:var(--muted); }
|
||||||
/* PRD 5.9 US-002 (R1): eticheta umana scurta sub pill — text mic; clasa `s-error`
|
/* Eticheta umana scurta sub pill — text mic; clasa `s-error` o coloreaza
|
||||||
o coloreaza (apare doar pe error/needs_*). Stare prin text, nu doar culoare. */
|
(apare doar pe error/needs_*). Stare prin text, nu doar culoare. */
|
||||||
.tabel-trimiteri .eticheta-problema { font-size:12px; line-height:1.3; margin-top:3px; }
|
.tabel-trimiteri .eticheta-problema { font-size:12px; line-height:1.3; margin-top:3px; }
|
||||||
/* PRD 5.9 US-002 (R8): randul e clickabil (deschide modalul) -> tinta de atins >=44px
|
/* Randul e clickabil (deschide modalul) -> tinta de atins >=44px (touch) +
|
||||||
(touch) + afordanta hover/focus. Inlocuieste vechea regula `@media pointer:coarse
|
afordanta hover/focus. */
|
||||||
.chevron` (chevron eliminat); este SINGURA regula 44px pe rand. */
|
|
||||||
.tabel-trimiteri tr.trimitere-row { min-height:44px; }
|
.tabel-trimiteri tr.trimitere-row { min-height:44px; }
|
||||||
.tabel-trimiteri tr.trimitere-row > td { padding-top:11px; padding-bottom:11px; }
|
.tabel-trimiteri tr.trimitere-row > td { padding-top:11px; padding-bottom:11px; }
|
||||||
.tabel-trimiteri tr.trimitere-row:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
|
.tabel-trimiteri tr.trimitere-row:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
|
||||||
@@ -321,10 +311,10 @@
|
|||||||
@media (max-width:1024px) {
|
@media (max-width:1024px) {
|
||||||
.tabel-trimiteri .col-actualizat { display:none; }
|
.tabel-trimiteri .col-actualizat { display:none; }
|
||||||
}
|
}
|
||||||
/* === Modal detaliu (PRD 5.9 US-003): fereastra modala globala, in afara zonei de
|
/* === Modal detaliu: fereastra modala globala, in afara zonei de poll
|
||||||
poll (#submissions-wrap). Backdrop + dialog centrat pe desktop; focus-trap +
|
(#submissions-wrap). Backdrop + dialog centrat pe desktop; focus-trap +
|
||||||
scroll-lock + inert pe <main> sunt in JS. Varianta full-screen mobil: vezi blocul
|
scroll-lock + inert pe <main> sunt in JS. Varianta full-screen mobil: vezi blocul
|
||||||
`@media (max-width:767px)` US-006 de mai jos. === */
|
`@media (max-width:767px)` de mai jos. === */
|
||||||
.modal-overlay { position:fixed; inset:0; z-index:1100; display:flex;
|
.modal-overlay { position:fixed; inset:0; z-index:1100; display:flex;
|
||||||
align-items:flex-start; justify-content:center; padding:40px 16px; overflow-y:auto; }
|
align-items:flex-start; justify-content:center; padding:40px 16px; overflow-y:auto; }
|
||||||
.modal-overlay[hidden] { display:none; }
|
.modal-overlay[hidden] { display:none; }
|
||||||
@@ -341,9 +331,9 @@
|
|||||||
body.modal-open { overflow:hidden; }
|
body.modal-open { overflow:hidden; }
|
||||||
.modal-eroare { padding:16px 4px; }
|
.modal-eroare { padding:16px 4px; }
|
||||||
.modal-eroare .actiuni { margin-top:12px; display:flex; gap:10px; flex-wrap:wrap; }
|
.modal-eroare .actiuni { margin-top:12px; display:flex; gap:10px; flex-wrap:wrap; }
|
||||||
/* === PRD 5.9 US-006: fundatie responsive mobil (<768px) ===
|
/* === Fundatie responsive mobil (<768px) ===
|
||||||
Breakpoint unic 767px (vezi conventia de sus). Cuprinde: card per rand pe tabelul
|
Breakpoint unic 767px (vezi conventia de sus). Cuprinde: card per rand pe tabelul
|
||||||
de trimiteri (5.8, pastrat), modal full-screen, header/nav colapsat cu tinte touch
|
de trimiteri, modal full-screen, header/nav colapsat cu tinte touch
|
||||||
>=44px. Desktop (>=1024px) ramane neschimbat — regulile de baza nu se modifica. */
|
>=44px. Desktop (>=1024px) ramane neschimbat — regulile de baza nu se modifica. */
|
||||||
@media (max-width:767px) {
|
@media (max-width:767px) {
|
||||||
/* Tabel trimiteri: card per rand (eticheta:valoare stivuit) -> fara scroll orizontal */
|
/* Tabel trimiteri: card per rand (eticheta:valoare stivuit) -> fara scroll orizontal */
|
||||||
@@ -366,7 +356,7 @@
|
|||||||
padding:16px; padding-top:56px; overflow-y:auto; }
|
padding:16px; padding-top:56px; overflow-y:auto; }
|
||||||
.modal-close { width:44px; height:44px; top:8px; right:8px; font-size:24px; }
|
.modal-close { width:44px; height:44px; top:8px; right:8px; font-size:24px; }
|
||||||
|
|
||||||
/* US-004 (R11): actiunile de jos din detaliu (Re-pune / Sterge) full-width stivuit pe mobil. */
|
/* Actiunile de jos din detaliu (Re-pune / Sterge) full-width stivuit pe mobil. */
|
||||||
.detaliu-actiuni-jos button { width:100%; }
|
.detaliu-actiuni-jos button { width:100%; }
|
||||||
|
|
||||||
/* Header + nav colapsate: pe mobil trece de la grid la flex wrap.
|
/* Header + nav colapsate: pe mobil trece de la grid la flex wrap.
|
||||||
@@ -383,9 +373,9 @@
|
|||||||
.tab-link { min-height:44px; padding:10px 14px; }
|
.tab-link { min-height:44px; padding:10px 14px; }
|
||||||
.cont-menu a, .cont-menu button { min-height:44px; }
|
.cont-menu a, .cont-menu button { min-height:44px; }
|
||||||
|
|
||||||
/* === PRD 5.9 US-007 (R12): paginile de continut pe mobil ===
|
/* === Paginile de continut pe mobil ===
|
||||||
Tabele ACTIONABILE (Mapari) -> card per rand. Clasa proprie `.tabel-card`,
|
Tabele ACTIONABILE (Mapari) -> card per rand. Clasa proprie `.tabel-card`,
|
||||||
scopata SEPARAT de `.tabel-trimiteri` (5.8) ca sa NU strice cardurile de
|
scopata SEPARAT de `.tabel-trimiteri` ca sa NU strice cardurile de
|
||||||
trimiteri. Tabele DENSE read-only (Jurnal, Nomenclator) + Admin raman in
|
trimiteri. Tabele DENSE read-only (Jurnal, Nomenclator) + Admin raman in
|
||||||
`.tablewrap` (scroll orizontal CONTAINED, definit global mai sus). */
|
`.tablewrap` (scroll orizontal CONTAINED, definit global mai sus). */
|
||||||
.tabel-card table { table-layout:auto; }
|
.tabel-card table { table-layout:auto; }
|
||||||
@@ -417,11 +407,11 @@
|
|||||||
#card-cont button, #form-test-cheie button,
|
#card-cont button, #form-test-cheie button,
|
||||||
#jurnal-section #filtre-jurnal button { min-height:44px; width:100%; }
|
#jurnal-section #filtre-jurnal button { min-height:44px; width:100%; }
|
||||||
|
|
||||||
/* === PRD 5.9 US-008: Acasa (upload, status, filtre) + login/signup pe mobil ===
|
/* === Acasa (upload, status, filtre) + login/signup pe mobil ===
|
||||||
Zona de upload, bara de status si bara de filtre (`_coada.html`) stiveaza pe O
|
Zona de upload, bara de status si bara de filtre (`_coada.html`) stiveaza pe O
|
||||||
coloana sub 767px; inputuri/butoane full-width cu tinta touch >=44px. Scopat pe
|
coloana sub 767px; inputuri/butoane full-width cu tinta touch >=44px. Scopat pe
|
||||||
id-urile sectiunilor de pe Acasa ca sa NU atinga tabelul de trimiteri (5.8),
|
id-urile sectiunilor de pe Acasa ca sa NU atinga tabelul de trimiteri,
|
||||||
modalul sau paginile de continut (US-007). */
|
modalul sau paginile de continut. */
|
||||||
/* Bara de upload: zona slim (returning user) trece pe coloana; butonul full-width. */
|
/* Bara de upload: zona slim (returning user) trece pe coloana; butonul full-width. */
|
||||||
#import-section .drop-zone { flex-direction:column; align-items:stretch; text-align:left; }
|
#import-section .drop-zone { flex-direction:column; align-items:stretch; text-align:left; }
|
||||||
#import-section #upload-btn { width:100%; min-height:44px; }
|
#import-section #upload-btn { width:100%; min-height:44px; }
|
||||||
@@ -441,16 +431,14 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{# US-012c (PRD 5.10): grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale).
|
{# Grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). #}
|
||||||
Decizie env badge: mutat in header-center sub <h1> (mic, color:muted) — nu suprapune logo-ul
|
|
||||||
si pastreaza centrarea optica a titlului in coloana auto. #}
|
|
||||||
<header>
|
<header>
|
||||||
{# Celula stanga: logo ROMFAST (US-012c: brand top-left ca pe romfast.ro) #}
|
{# Celula stanga: logo ROMFAST #}
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
{# US-012b/c: logo PNG real, 288x175 RGBA transparent — ok pe toate temele fara filtre. #}
|
{# Logo PNG real, RGBA transparent — ok pe toate temele fara filtre. #}
|
||||||
<img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
|
<img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
|
||||||
</div>
|
</div>
|
||||||
{# Celula centru: titlu + badge env mic (US-012c: env mutat din header-left aici) #}
|
{# Celula centru: titlu + badge env mic #}
|
||||||
<div class="header-center">
|
<div class="header-center">
|
||||||
<h1>Gateway RAR AUTOPASS</h1>
|
<h1>Gateway RAR AUTOPASS</h1>
|
||||||
<span class="env">{{ rar_env }}</span>
|
<span class="env">{{ rar_env }}</span>
|
||||||
@@ -462,14 +450,14 @@
|
|||||||
title="Comuta tema">☀</button>
|
title="Comuta tema">☀</button>
|
||||||
<span class="muted" style="font-size:13px;">v{{ version }}</span>
|
<span class="muted" style="font-size:13px;">v{{ version }}</span>
|
||||||
{% if is_authenticated|default(false) %}
|
{% if is_authenticated|default(false) %}
|
||||||
{# Meniu cont (US-006 PRD 5.5): Cont/Integrare/Nomenclator + (admin) + logout.
|
{# Meniu cont: Cont/Integrare/Nomenclator + (admin) + logout.
|
||||||
Pe paginile neautentificate (login/signup) nu se randeaza deloc. #}
|
Pe paginile neautentificate (login/signup) nu se randeaza deloc. #}
|
||||||
<div class="cont-menu-wrap">
|
<div class="cont-menu-wrap">
|
||||||
<button id="cont-menu-toggle" class="icon-btn"
|
<button id="cont-menu-toggle" class="icon-btn"
|
||||||
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
|
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
|
||||||
aria-label="Meniu cont" title="Meniu cont">☰</button>
|
aria-label="Meniu cont" title="Meniu cont">☰</button>
|
||||||
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden>
|
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden>
|
||||||
{# US-009 (PRD 5.10): Mapari mutat din tab-bar in meniu, cu badge needs_mapping. #}
|
{# Mapari, cu badge needs_mapping. #}
|
||||||
{% set _mapari_badge = (badges.mapari if (badges is defined and badges and badges.mapari) else 0) %}
|
{% set _mapari_badge = (badges.mapari if (badges is defined and badges and badges.mapari) else 0) %}
|
||||||
<a role="menuitem" href="/?tab=mapari">Mapari{% if _mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ _mapari_badge }}</span>{% endif %}</a>
|
<a role="menuitem" href="/?tab=mapari">Mapari{% if _mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ _mapari_badge }}</span>{% endif %}</a>
|
||||||
<hr>
|
<hr>
|
||||||
@@ -488,14 +476,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{# aria-live pentru anuntarea schimbarilor de tema (US-014, accesibilitate) #}
|
{# aria-live pentru anuntarea schimbarilor de tema (accesibilitate) #}
|
||||||
<span id="tema-live" role="status" aria-live="polite"
|
<span id="tema-live" role="status" aria-live="polite"
|
||||||
style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;"></span>
|
style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;"></span>
|
||||||
<main>{% block content %}{% endblock %}</main>
|
<main>{% block content %}{% endblock %}</main>
|
||||||
{# Modal detaliu trimitere (PRD 5.9 US-003): container global, SIBLING al <main>
|
{# Modal detaliu trimitere: container global, SIBLING al <main> (nu descendent),
|
||||||
(nu descendent), ca `inert`+`aria-hidden` pe <main> sa nu-l prinda si pe el (R7).
|
ca `inert`+`aria-hidden` pe <main> sa nu-l prinda si pe el. Corpul
|
||||||
Corpul #detaliu-modal-body e tinta de swap pentru fragment + rutele corectie/
|
#detaliu-modal-body e tinta de swap pentru fragment + rutele corectie/mapare/
|
||||||
mapare/lifecycle. Traieste in afara #submissions-wrap -> poll-ul de 15s nu-l atinge. #}
|
lifecycle. Traieste in afara #submissions-wrap -> poll-ul nu-l atinge. #}
|
||||||
<div id="modal-detaliu" class="modal-overlay" role="dialog" aria-modal="true"
|
<div id="modal-detaliu" class="modal-overlay" role="dialog" aria-modal="true"
|
||||||
aria-labelledby="detaliu-modal-titlu" hidden>
|
aria-labelledby="detaliu-modal-titlu" hidden>
|
||||||
<div class="modal-backdrop" data-modal-close></div>
|
<div class="modal-backdrop" data-modal-close></div>
|
||||||
@@ -505,7 +493,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
// Comutator tema ciclic (US-014 PRD 5.10): click cicleaza Light->Dark->Petrol->Auto.
|
// Comutator tema ciclic: click cicleaza Light->Dark->Petrol->Auto.
|
||||||
// Separare init (sincronizare iconita/label) de persistenta (doar la click explicit).
|
// Separare init (sincronizare iconita/label) de persistenta (doar la click explicit).
|
||||||
// 'auto' se rezolva la paint prin anti-FOUC; aici setam data-theme rezolvat.
|
// 'auto' se rezolva la paint prin anti-FOUC; aici setam data-theme rezolvat.
|
||||||
(function() {
|
(function() {
|
||||||
@@ -528,7 +516,7 @@
|
|||||||
var s = VALID[stored] ? stored : 'auto';
|
var s = VALID[stored] ? stored : 'auto';
|
||||||
btn.innerHTML = ICONS[s];
|
btn.innerHTML = ICONS[s];
|
||||||
btn.setAttribute('aria-label', 'Tema: ' + LABELS[s] + ', apasa pentru ' + NEXT[s]);
|
btn.setAttribute('aria-label', 'Tema: ' + LABELS[s] + ', apasa pentru ' + NEXT[s]);
|
||||||
btn.title = LABELS[s]; // US-014b: doar numele temei (ex. "Petrol"), nu ciclul intreg
|
btn.title = LABELS[s]; // doar numele temei (ex. "Petrol"), nu ciclul intreg
|
||||||
}
|
}
|
||||||
function _setTheme(t) {
|
function _setTheme(t) {
|
||||||
document.documentElement.setAttribute('data-theme', _resolved(t));
|
document.documentElement.setAttribute('data-theme', _resolved(t));
|
||||||
@@ -547,7 +535,7 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// Meniu cont (US-006 PRD 5.5): dropdown ancorat dreapta-sus. Deschide/inchide la click,
|
// Meniu cont: dropdown ancorat dreapta-sus. Deschide/inchide la click,
|
||||||
// inchide la Esc (focus readus pe buton) si la click in afara. Fara dependente.
|
// inchide la Esc (focus readus pe buton) si la click in afara. Fara dependente.
|
||||||
(function() {
|
(function() {
|
||||||
var toggle = document.getElementById('cont-menu-toggle');
|
var toggle = document.getElementById('cont-menu-toggle');
|
||||||
@@ -624,7 +612,7 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// US-011: dirty state pentru butoanele de salvare din tabelele de mapari.
|
// Dirty state pentru butoanele de salvare din tabelele de mapari.
|
||||||
// Cand utilizatorul schimba un select dintr-un form de mapare, butonul de salvare
|
// Cand utilizatorul schimba un select dintr-un form de mapare, butonul de salvare
|
||||||
// legat prin data-dirty-form devine evidentiat (clasa "dirty" → fundal --accent).
|
// legat prin data-dirty-form devine evidentiat (clasa "dirty" → fundal --accent).
|
||||||
// Starea "dirty" e efemera per-render: un swap outerHTML o reseteaza automat.
|
// Starea "dirty" e efemera per-render: un swap outerHTML o reseteaza automat.
|
||||||
@@ -700,11 +688,11 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// Modal detaliu trimitere (PRD 5.9 US-003): inlocuieste detaliul inline (5.8). Detaliul
|
// Modal detaliu trimitere: detaliul se incarca prin HTMX in #detaliu-modal-body
|
||||||
// se incarca prin HTMX in #detaliu-modal-body (in afara #submissions-wrap, deci poll-ul
|
// (in afara #submissions-wrap, deci poll-ul nu-l atinge). Aici: deschidere la click
|
||||||
// de 15s nu-l atinge). Aici: deschidere la click pe rand, inchidere (x/Esc/backdrop),
|
// pe rand, inchidere (x/Esc/backdrop), focus-trap, scroll-lock, inert+aria-hidden pe
|
||||||
// focus-trap, scroll-lock, inert+aria-hidden pe <main> (R7), stare de eroare la load
|
// <main>, stare de eroare la load esuat, inchidere pe succes corectie/sterge
|
||||||
// esuat (R5), inchidere pe succes corectie/sterge (HX-Trigger inchideModal, R5).
|
// (HX-Trigger inchideModal).
|
||||||
(function() {
|
(function() {
|
||||||
var overlay = document.getElementById('modal-detaliu');
|
var overlay = document.getElementById('modal-detaliu');
|
||||||
if (!overlay) return;
|
if (!overlay) return;
|
||||||
@@ -721,7 +709,7 @@
|
|||||||
' select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'),
|
' select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'),
|
||||||
function(el) { return el.offsetParent !== null || el === document.activeElement; });
|
function(el) { return el.offsetParent !== null || el === document.activeElement; });
|
||||||
}
|
}
|
||||||
// R7: focus-trap — Tab/Shift+Tab cicleaza in interiorul dialogului.
|
// focus-trap — Tab/Shift+Tab cicleaza in interiorul dialogului.
|
||||||
function trapFocus(e) {
|
function trapFocus(e) {
|
||||||
if (e.key !== 'Tab') return;
|
if (e.key !== 'Tab') return;
|
||||||
var f = focusable();
|
var f = focusable();
|
||||||
@@ -755,7 +743,6 @@
|
|||||||
if (t && t.focus) t.focus(); // focus readus pe rand
|
if (t && t.focus) t.focus(); // focus readus pe rand
|
||||||
}
|
}
|
||||||
// API public: butonul „Inchide" din fragment + inchiderea pe succes corectie/sterge.
|
// API public: butonul „Inchide" din fragment + inchiderea pe succes corectie/sterge.
|
||||||
// (Semnatura veche inchideDetaliu(id) pastrata, dar exista un singur modal o data.)
|
|
||||||
window.inchideDetaliu = function() { close(); };
|
window.inchideDetaliu = function() { close(); };
|
||||||
|
|
||||||
// Inchidere: x si backdrop (elemente cu data-modal-close), Esc.
|
// Inchidere: x si backdrop (elemente cu data-modal-close), Esc.
|
||||||
@@ -778,7 +765,7 @@
|
|||||||
var f = focusable();
|
var f = focusable();
|
||||||
if (f.length) f[0].focus();
|
if (f.length) f[0].focus();
|
||||||
});
|
});
|
||||||
// R5: load-error al fragmentului (GET esuat) -> stare Reincearca/Inchide, nu placeholder blocat.
|
// Load-error al fragmentului (GET esuat) -> stare Reincearca/Inchide, nu placeholder blocat.
|
||||||
body.addEventListener('htmx:responseError', function(evt) {
|
body.addEventListener('htmx:responseError', function(evt) {
|
||||||
if (!isOpen()) return;
|
if (!isOpen()) return;
|
||||||
var elt = evt.detail && evt.detail.elt;
|
var elt = evt.detail && evt.detail.elt;
|
||||||
@@ -796,7 +783,7 @@
|
|||||||
{ target: body, swap: 'innerHTML' });
|
{ target: body, swap: 'innerHTML' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// R5: inchidere pe succes corectie/sterge — ruta emite HX-Trigger `inchideModal`.
|
// Inchidere pe succes corectie/sterge — ruta emite HX-Trigger `inchideModal`.
|
||||||
// Lista se reincarca separat prin `trimiteriChanged` (#submissions-wrap). Maparea
|
// Lista se reincarca separat prin `trimiteriChanged` (#submissions-wrap). Maparea
|
||||||
// inline NU emite inchideModal -> modalul ramane deschis sa arate codul rezolvat.
|
// inline NU emite inchideModal -> modalul ramane deschis sa arate codul rezolvat.
|
||||||
document.body.addEventListener('inchideModal', function() { close(); });
|
document.body.addEventListener('inchideModal', function() { close(); });
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{# US-009 (PRD 5.10): tab-bar-ul Acasa/Mapari a fost eliminat. Mapari s-a mutat in meniul
|
|
||||||
hamburger (#cont-menu in base.html). Acasa e continutul principal direct — nicio schela ARIA
|
|
||||||
role="tablist"/"tab"/"tabpanel" orfana. Rutele /_fragments/* si deep-link-urile ?tab=
|
|
||||||
raman valide (navigare prin meniu → full page reload). #}
|
|
||||||
|
|
||||||
<!-- Bara de status: mereu vizibila -->
|
<!-- Bara de status: mereu vizibila -->
|
||||||
<div id="status-bar" class="status-bar card"
|
<div id="status-bar" class="status-bar card"
|
||||||
hx-get="/_fragments/status"
|
hx-get="/_fragments/status"
|
||||||
|
|||||||
@@ -1,26 +1,23 @@
|
|||||||
"""Worker RAR — proces propriu (NU task asyncio in uvicorn; plan.md sect. 4).
|
"""Worker RAR — proces propriu (NU task asyncio in uvicorn).
|
||||||
|
|
||||||
Bucla: heartbeat -> recupereaza orfane -> claim atomic -> login -> postPrezentare -> update.
|
Bucla: heartbeat -> recupereaza orfane -> claim atomic -> login -> postPrezentare -> update.
|
||||||
Ruleaza ca proces separat sub `restart: always` (docker compose).
|
Ruleaza ca proces separat sub `restart: always` (docker compose).
|
||||||
|
|
||||||
T2 implementat:
|
|
||||||
- claim atomic anti-race (BEGIN IMMEDIATE), respecta next_attempt_at (backoff).
|
- claim atomic anti-race (BEGIN IMMEDIATE), respecta next_attempt_at (backoff).
|
||||||
- reconciliere anti-duplicat pe raspuns pierdut: pe eroare tranzitorie/timeout SAU pe
|
- reconciliere anti-duplicat pe raspuns pierdut: pe eroare tranzitorie/timeout SAU pe
|
||||||
rand 'sending' orfan (worker mort mid-POST), interogheaza finalizate si match pe
|
rand 'sending' orfan (worker mort mid-POST), interogheaza finalizate si match pe
|
||||||
vin+dataPrestatie+odometruFinal; daca exista -> 'sent' (NU re-trimite).
|
vin+dataPrestatie+odometruFinal; daca exista -> 'sent' (NU re-trimite).
|
||||||
- retry/backoff exponential pe erori tranzitorii; peste worker_max_retries -> 'error' (banner).
|
- retry/backoff exponential pe erori tranzitorii; peste worker_max_retries -> 'error'.
|
||||||
- lease/timeout pe randuri 'sending' orfane.
|
- lease/timeout pe randuri 'sending' orfane.
|
||||||
- re-login la token expirat (401 mid-sesiune) — JWT 30h, retry NU plafonat la 30h.
|
- re-login la token expirat (401 mid-sesiune) — JWT 30h, retry NU plafonat la 30h.
|
||||||
|
|
||||||
Creds per-cerere (plan sect. 5): fiecare submission poarta creds RAR CRIPTATE
|
Creds per-cerere: fiecare submission poarta creds RAR CRIPTATE (rar_creds_enc).
|
||||||
(rar_creds_enc). Worker-ul face login per CONT cu acele creds, cache-uieste JWT
|
Worker-ul face login per CONT cu acele creds, cache-uieste JWT (30h) in memorie si
|
||||||
(30h) in memorie si STERGE creds-urile contului dupa primul login reusit. Token-ul
|
STERGE creds-urile contului dupa primul login reusit. Token-ul in memorie acopera
|
||||||
in memorie acopera restul trimiterilor; la restart token-ul se pierde si contul
|
restul trimiterilor; la restart token-ul se pierde si contul re-logheaza la urmatorul
|
||||||
re-loghează la urmatorul submission care aduce creds proaspete (degradare acceptata).
|
submission care aduce creds proaspete (degradare acceptata).
|
||||||
Dev: `worker_use_test_creds` foloseste creds <test> cand submission-ul nu are enc.
|
Dev: `worker_use_test_creds` foloseste creds <test> cand submission-ul nu are enc.
|
||||||
|
|
||||||
Ce NU e inca: criptare PII payload at-rest (P2), b64Image mare pe disc (P2).
|
|
||||||
|
|
||||||
Pornire: python -m app.worker
|
Pornire: python -m app.worker
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -61,8 +58,8 @@ def _iso(dt: datetime) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _wlog(conn, tip: str, mesaj: str, *, nivel: str = "INFO", account_id=None, cod=None, context=None) -> None:
|
def _wlog(conn, tip: str, mesaj: str, *, nivel: str = "INFO", account_id=None, cod=None, context=None) -> None:
|
||||||
"""Migrare print -> jurnal structurat (US-005): emite evenimentul (sursa=worker, dublu
|
"""Emite evenimentul (sursa=worker, dublu canal DB+fisier) SI pastreaza linia in
|
||||||
canal DB+fisier) SI pastreaza linia in stdout (operatorul tailuieste .run/worker.log)."""
|
stdout (operatorul tailuieste .run/worker.log)."""
|
||||||
print(f"[worker] {mesaj}", flush=True)
|
print(f"[worker] {mesaj}", flush=True)
|
||||||
log_event(tip, nivel=nivel, account_id=account_id, cod=cod, mesaj=mesaj, context=context,
|
log_event(tip, nivel=nivel, account_id=account_id, cod=cod, mesaj=mesaj, context=context,
|
||||||
conn=conn, sursa="worker")
|
conn=conn, sursa="worker")
|
||||||
@@ -84,17 +81,17 @@ def _is_transient(exc: Exception) -> bool:
|
|||||||
|
|
||||||
# --- Operatii pe submissions ---
|
# --- Operatii pe submissions ---
|
||||||
|
|
||||||
# Stari blocate ne-sent care primesc retentie proprie (US-013). Mai scurta decat
|
# Stari blocate ne-sent care primesc retentie proprie. Mai scurta decat cele 90z
|
||||||
# cele 90z ale `sent`: un blocat n-are valoare de audit ca o trimitere reusita.
|
# ale `sent`: un blocat n-are valoare de audit ca o trimitere reusita.
|
||||||
_BLOCKED_STATES = ("error", "needs_data", "needs_mapping")
|
_BLOCKED_STATES = ("error", "needs_data", "needs_mapping")
|
||||||
|
|
||||||
|
|
||||||
def mark(conn, submission_id: int, status: str, *, rar_status_code=None, rar_error=None, id_prezentare=None) -> None:
|
def mark(conn, submission_id: int, status: str, *, rar_status_code=None, rar_error=None, id_prezentare=None) -> None:
|
||||||
if status == "sent":
|
if status == "sent":
|
||||||
# T16: purge_after = sent + 90 zile (GDPR/L.142 retentie maxima).
|
# purge_after = sent + 90 zile (GDPR/L.142 retentie maxima).
|
||||||
purge_expr = "datetime('now', '+90 days')"
|
purge_expr = "datetime('now', '+90 days')"
|
||||||
elif status in _BLOCKED_STATES:
|
elif status in _BLOCKED_STATES:
|
||||||
# US-013: randurile blocate primesc si ele purge_after (altfel raman permanent).
|
# Randurile blocate primesc si ele purge_after (altfel raman permanent).
|
||||||
days = int(get_settings().blocked_retention_days)
|
days = int(get_settings().blocked_retention_days)
|
||||||
purge_expr = f"datetime('now', '+{days} days')"
|
purge_expr = f"datetime('now', '+{days} days')"
|
||||||
else:
|
else:
|
||||||
@@ -114,15 +111,15 @@ def mark(conn, submission_id: int, status: str, *, rar_status_code=None, rar_err
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# T16: purge interval in secunde (odata pe ora, nu prea agresiv)
|
# Purge interval in secunde (odata pe ora, nu prea agresiv)
|
||||||
_PURGE_INTERVAL_S = 3600
|
_PURGE_INTERVAL_S = 3600
|
||||||
|
|
||||||
|
|
||||||
def purge_expired(conn) -> dict[str, int]:
|
def purge_expired(conn) -> dict[str, int]:
|
||||||
"""Sterge randurile expirate (purge_after < now).
|
"""Sterge randurile expirate (purge_after < now).
|
||||||
|
|
||||||
T16/OV-5 + US-013/US-008: submissions `sent` SI blocate (error/needs_data/needs_mapping)
|
Submissions `sent` SI blocate (error/needs_data/needs_mapping) expirate;
|
||||||
expirate; import_batches expirate (import_rows via CASCADE); app_events expirate (jurnal).
|
import_batches expirate (import_rows via CASCADE); app_events expirate (jurnal).
|
||||||
EXCLUDE explicit `queued`/`sending` (randuri active — nu se purjeaza niciodata, chiar
|
EXCLUDE explicit `queued`/`sending` (randuri active — nu se purjeaza niciodata, chiar
|
||||||
daca ar avea un purge_after rezidual; reactivarea il curata oricum).
|
daca ar avea un purge_after rezidual; reactivarea il curata oricum).
|
||||||
Intoarce {submissions_purged, batches_purged, events_purged}.
|
Intoarce {submissions_purged, batches_purged, events_purged}.
|
||||||
@@ -174,7 +171,7 @@ def claim_one(conn) -> dict | None:
|
|||||||
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
|
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
|
||||||
"WHERE s.status='queued' "
|
"WHERE s.status='queued' "
|
||||||
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
|
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
|
||||||
# Gate pe stare de cont (5.5): doar 'active' trimite. Derivam defensiv din `active`
|
# Gate pe stare de cont: doar 'active' trimite. Derivam defensiv din `active`
|
||||||
# cand `status` lipseste (DB veche pre-migrare), pastrand active=1 <=> 'active'.
|
# cand `status` lipseste (DB veche pre-migrare), pastrand active=1 <=> 'active'.
|
||||||
"AND COALESCE(a.status, CASE WHEN COALESCE(a.active,1)=1 THEN 'active' ELSE 'pending' END) = 'active' "
|
"AND COALESCE(a.status, CASE WHEN COALESCE(a.active,1)=1 THEN 'active' ELSE 'pending' END) = 'active' "
|
||||||
"ORDER BY s.id LIMIT 1",
|
"ORDER BY s.id LIMIT 1",
|
||||||
@@ -253,7 +250,7 @@ def process_one(conn, settings: Settings, rar: RarClient, token: str, claimed: d
|
|||||||
# RAR a raspuns DEFINITIV cu o eroare de procesare (ex. ORA-12899). NU e o
|
# RAR a raspuns DEFINITIV cu o eroare de procesare (ex. ORA-12899). NU e o
|
||||||
# pierdere de raspuns ambigua -> NU reconcilia (recordul, daca exista la RAR,
|
# pierdere de raspuns ambigua -> NU reconcilia (recordul, daca exista la RAR,
|
||||||
# e PARTIAL/rupt si nu trebuie marcat fals 'sent') si NU reincerca (acelasi
|
# e PARTIAL/rupt si nu trebuie marcat fals 'sent') si NU reincerca (acelasi
|
||||||
# input va esua iar). Marcam 'error' cu mesajul real RAR. (Confirmat live 2026-06-23.)
|
# input va esua iar). Marcam 'error' cu mesajul real RAR.
|
||||||
detail = json.dumps(errors.eroare("RAR_EROARE_SERVER", cauza=exc.rar_message), ensure_ascii=False)
|
detail = json.dumps(errors.eroare("RAR_EROARE_SERVER", cauza=exc.rar_message), ensure_ascii=False)
|
||||||
mark(conn, sid, "error", rar_status_code=500, rar_error=detail)
|
mark(conn, sid, "error", rar_status_code=500, rar_error=detail)
|
||||||
_wlog(conn, "submission_error", f"submission {sid} -> error (RAR 500): {exc.rar_message}",
|
_wlog(conn, "submission_error", f"submission {sid} -> error (RAR 500): {exc.rar_message}",
|
||||||
@@ -363,7 +360,7 @@ class AccountSessions:
|
|||||||
token = rar.login(creds["email"], creds["password"])
|
token = rar.login(creds["email"], creds["password"])
|
||||||
except RarAuthError as exc:
|
except RarAuthError as exc:
|
||||||
rar.close()
|
rar.close()
|
||||||
# US-005: login esuat (401) — FARA email/parola (doar codul HTTP + contul).
|
# Login esuat (401) — FARA email/parola (doar codul HTTP + contul).
|
||||||
log_event("rar_login", nivel="WARNING", account_id=account_id,
|
log_event("rar_login", nivel="WARNING", account_id=account_id,
|
||||||
cod="RAR_CREDS_INVALIDE",
|
cod="RAR_CREDS_INVALIDE",
|
||||||
mesaj=f"login RAR esuat (cont {account_id}): {exc.status_code or 401}",
|
mesaj=f"login RAR esuat (cont {account_id}): {exc.status_code or 401}",
|
||||||
@@ -375,11 +372,11 @@ class AccountSessions:
|
|||||||
raise
|
raise
|
||||||
self._sessions[account_id] = (rar, token)
|
self._sessions[account_id] = (rar, token)
|
||||||
write_heartbeat(conn, rar_login_ok=True, detail=f"login RAR ok (cont {account_id})")
|
write_heartbeat(conn, rar_login_ok=True, detail=f"login RAR ok (cont {account_id})")
|
||||||
# US-005: login reusit (fara email/parola in clar — context curat).
|
# Login reusit (fara email/parola in clar — context curat).
|
||||||
log_event("rar_login", account_id=account_id, mesaj=f"login RAR ok (cont {account_id})",
|
log_event("rar_login", account_id=account_id, mesaj=f"login RAR ok (cont {account_id})",
|
||||||
context={"rezultat": "ok", "http": 200}, conn=conn, sursa="worker")
|
context={"rezultat": "ok", "http": 200}, conn=conn, sursa="worker")
|
||||||
# Creds efemere pe submissions nu mai sunt necesare: JWT acopera retry-urile -> sterge.
|
# Creds efemere pe submissions nu mai sunt necesare: JWT acopera retry-urile -> sterge.
|
||||||
# GATE PURJARE (T1/Voce#5): sterge DOAR submissions.rar_creds_enc, NU accounts.rar_creds_enc.
|
# GATE PURJARE: sterge DOAR submissions.rar_creds_enc, NU accounts.rar_creds_enc.
|
||||||
# Canal web: fallback exista in accounts -> purjarea e inofensiva (re-login dupa restart).
|
# Canal web: fallback exista in accounts -> purjarea e inofensiva (re-login dupa restart).
|
||||||
# Canal API pur: purjarea e identica cu Treapta 1 (neatinsa).
|
# Canal API pur: purjarea e identica cu Treapta 1 (neatinsa).
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -418,7 +415,7 @@ def _creds_for(claimed: dict, settings: Settings) -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
def _creds_from_account(conn, account_id: int) -> dict | None:
|
def _creds_from_account(conn, account_id: int) -> dict | None:
|
||||||
"""Fallback T1/D4: crede RAR durabile per-cont din accounts.rar_creds_enc.
|
"""Fallback: crede RAR durabile per-cont din accounts.rar_creds_enc.
|
||||||
|
|
||||||
Canal web nu are re-pusher. Cand submission n-are creds (sterse dupa primul login
|
Canal web nu are re-pusher. Cand submission n-are creds (sterse dupa primul login
|
||||||
sau upload web fara creds), worker-ul re-citeste din cont si poate re-login oricand.
|
sau upload web fara creds), worker-ul re-citeste din cont si poate re-login oricand.
|
||||||
@@ -436,7 +433,7 @@ def run() -> int:
|
|||||||
signal.signal(signal.SIGINT, _stop)
|
signal.signal(signal.SIGINT, _stop)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
set_source("worker") # US-005: evenimentele worker-ului au sursa=worker (fisier app-worker.log)
|
set_source("worker") # evenimentele worker-ului au sursa=worker (fisier app-worker.log)
|
||||||
init_db()
|
init_db()
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
print(f"[worker] pornit (send_enabled={settings.worker_send_enabled}, env={settings.rar_env})", flush=True)
|
print(f"[worker] pornit (send_enabled={settings.worker_send_enabled}, env={settings.rar_env})", flush=True)
|
||||||
@@ -448,7 +445,7 @@ def run() -> int:
|
|||||||
try:
|
try:
|
||||||
write_heartbeat(conn, detail=f"poll (queue={_queue_depth(conn)})")
|
write_heartbeat(conn, detail=f"poll (queue={_queue_depth(conn)})")
|
||||||
|
|
||||||
# T16: purjare periodica (odata pe ora) — NU mai frecvent.
|
# Purjare periodica (odata pe ora) — NU mai frecvent.
|
||||||
now_ts = time.time()
|
now_ts = time.time()
|
||||||
if now_ts - _last_purge_time >= _PURGE_INTERVAL_S:
|
if now_ts - _last_purge_time >= _PURGE_INTERVAL_S:
|
||||||
stats = purge_expired(conn)
|
stats = purge_expired(conn)
|
||||||
@@ -474,20 +471,20 @@ def run() -> int:
|
|||||||
|
|
||||||
sid = claimed["id"]
|
sid = claimed["id"]
|
||||||
account_id = claimed["account_id"]
|
account_id = claimed["account_id"]
|
||||||
# T1/US-012: randul poarta creds proaspete (rar_creds_enc != NULL) — fie prima
|
# Randul poarta creds proaspete (rar_creds_enc != NULL) — fie prima trimitere
|
||||||
# trimitere a contului, fie o REACTIVARE dupa creds gresite. Invalidam sesiunea
|
# a contului, fie o REACTIVARE dupa creds gresite. Invalidam sesiunea RAR
|
||||||
# RAR cache-uita ca un JWT vechi (30h) din parola GRESITA sa nu trimita cu ea,
|
# cache-uita ca un JWT vechi (30h) din parola GRESITA sa nu trimita cu ea,
|
||||||
# ignorand corectia. Re-login imediat cu creds-urile noi.
|
# ignorand corectia. Re-login imediat cu creds-urile noi.
|
||||||
if claimed.get("creds_enc"):
|
if claimed.get("creds_enc"):
|
||||||
sessions.invalidate(account_id)
|
sessions.invalidate(account_id)
|
||||||
# T1/D4: incearca creds din submission (canal API efemer), cu fallback la
|
# Incearca creds din submission (canal API efemer), cu fallback la
|
||||||
# accounts.rar_creds_enc (canal web durabil). Canal web n-are re-pusher.
|
# accounts.rar_creds_enc (canal web durabil). Canal web n-are re-pusher.
|
||||||
creds = _creds_for(claimed, settings) or _creds_from_account(conn, account_id)
|
creds = _creds_for(claimed, settings) or _creds_from_account(conn, account_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
token = sessions.get_token(conn, account_id, creds)
|
token = sessions.get_token(conn, account_id, creds)
|
||||||
except RarAuthError as exc:
|
except RarAuthError as exc:
|
||||||
# Creds gresite (login 401): NU se face retry (plan, failure registry).
|
# Creds gresite (login 401): NU se face retry.
|
||||||
mark(conn, sid, "error", rar_status_code=401,
|
mark(conn, sid, "error", rar_status_code=401,
|
||||||
rar_error=json.dumps(errors.eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide"), ensure_ascii=False))
|
rar_error=json.dumps(errors.eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide"), ensure_ascii=False))
|
||||||
# rar_login esuat e deja logat in get_token; aici doar tranzitia submission-ului.
|
# rar_login esuat e deja logat in get_token; aici doar tranzitia submission-ului.
|
||||||
|
|||||||
@@ -104,8 +104,6 @@ def test_modal_fullscreen_clasa_mobil(client):
|
|||||||
|
|
||||||
# Exista un bloc media mobil care vizeaza modalul.
|
# Exista un bloc media mobil care vizeaza modalul.
|
||||||
assert "@media (max-width:767px)" in html
|
assert "@media (max-width:767px)" in html
|
||||||
# Markerul US-006 pentru modalul full-screen pe mobil.
|
|
||||||
assert "US-006" in html
|
|
||||||
# Dialogul ocupa tot ecranul pe mobil (latime/inaltime pline, fara border-radius lateral).
|
# Dialogul ocupa tot ecranul pe mobil (latime/inaltime pline, fara border-radius lateral).
|
||||||
mobil = html[html.find("@media (max-width:767px)"):]
|
mobil = html[html.find("@media (max-width:767px)"):]
|
||||||
assert "100vw" in mobil or "width:100%" in mobil
|
assert "100vw" in mobil or "width:100%" in mobil
|
||||||
@@ -184,7 +182,6 @@ def test_tabele_continut_au_clasa_responsive(client):
|
|||||||
assert 'data-eticheta="Daca operatia contine"' in mapari
|
assert 'data-eticheta="Daca operatia contine"' in mapari
|
||||||
# Regula de card e definita o data in base.html, scopata pe `.tabel-card`.
|
# Regula de card e definita o data in base.html, scopata pe `.tabel-card`.
|
||||||
assert ".tabel-card thead" in mapari
|
assert ".tabel-card thead" in mapari
|
||||||
assert "US-007" in mapari
|
|
||||||
|
|
||||||
# --- Jurnal = scroll contained. Cu un eveniment seedat, tabelul apare in `.tablewrap`,
|
# --- Jurnal = scroll contained. Cu un eveniment seedat, tabelul apare in `.tablewrap`,
|
||||||
# NU ca `.tabel-card`. ---
|
# NU ca `.tabel-card`. ---
|
||||||
@@ -259,7 +256,6 @@ def test_acasa_fara_scroll_orizontal_mobil(client):
|
|||||||
assert 'id="import-section"' in html
|
assert 'id="import-section"' in html
|
||||||
assert 'id="status-bar"' in html
|
assert 'id="status-bar"' in html
|
||||||
assert 'id="filtre-trimiteri"' in html
|
assert 'id="filtre-trimiteri"' in html
|
||||||
assert "US-008" in html
|
|
||||||
|
|
||||||
mobil = html[html.find("@media (max-width:767px)"):]
|
mobil = html[html.find("@media (max-width:767px)"):]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user