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
|
||||
GET /v1/import/{id}/export-failed — CSV cu randuri esuate (needs_data/needs_mapping/needs_review)
|
||||
|
||||
Reguli cheie (plan §3.1-3.4, §12):
|
||||
- Issue 6: scrieri bulk in tranzactie explicita BEGIN IMMEDIATE...COMMIT + executemany.
|
||||
- Eng#5: already_sent lookup BATCH (IN chunk ~900), nu N+1.
|
||||
- OV-3: duplicate_in_file EXCLUSIV la preview/commit. NU atinge reconcile.py/worker.
|
||||
- Issue 1 (TOCTOU): commit per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
|
||||
- Issue 5a: import_rows.raw_json CRIPTAT Fernet.
|
||||
- Issue 5b: fuzzy coloane refoloseste mapping.normalize_for_match (DRY).
|
||||
- T4/D3: drift semnatura coloane -> NU aplica orb, cere re-confirmare.
|
||||
Reguli cheie:
|
||||
- Scrieri bulk in tranzactie explicita BEGIN IMMEDIATE...COMMIT + executemany.
|
||||
- already_sent lookup BATCH (IN chunk ~900), nu N+1.
|
||||
- duplicate_in_file EXCLUSIV la preview/commit. NU atinge reconcile.py/worker.
|
||||
- TOCTOU: commit per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
|
||||
- import_rows.raw_json CRIPTAT Fernet.
|
||||
- Drift semnatura coloane -> NU aplica orb, cere re-confirmare.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -60,7 +59,7 @@ router = APIRouter(prefix="/v1/import", tags=["import"])
|
||||
# Marimea maxima a unui chunk pentru IN(...) SQLite (limite SQLite ~999)
|
||||
_IN_CHUNK = 900
|
||||
|
||||
# Campuri canonice si sinonimele lor pentru sugestie fuzzy coloane (Issue 5b/Eng#4)
|
||||
# Campuri canonice si sinonimele lor pentru sugestie fuzzy coloane
|
||||
_CANONICAL_SYNONYMS: dict[str, list[str]] = {
|
||||
"vin": ["VIN", "Serie sasiu", "Sasiu", "Serie", "Numar sasiu", "Nr sasiu", "Chassis"],
|
||||
"nr_inmatriculare": ["Nr inmatriculare", "Numar inmatriculare", "Numar auto", "Nr auto", "Numar", "Nr"],
|
||||
@@ -93,7 +92,7 @@ def _fuzzy_suggest_column(
|
||||
) -> list[dict]:
|
||||
"""Sugereaza campuri canonice pentru o coloana din fisier.
|
||||
|
||||
Refoloseste normalize_for_match + rapidfuzz.fuzz.token_sort_ratio (Issue 5b/Eng#4).
|
||||
Refoloseste normalize_for_match + rapidfuzz.fuzz.token_sort_ratio.
|
||||
Intoarce [{camp_canonic, score}] sortat descrescator.
|
||||
"""
|
||||
from rapidfuzz import fuzz, process
|
||||
@@ -140,10 +139,10 @@ def _resolve_row_for_preview(
|
||||
errors: lista erori validare
|
||||
flags: motive needs_review
|
||||
|
||||
`override` (3.6, Approach B): patch CANONIC editat in preview, aplicat ULTIMUL
|
||||
peste valorile mapate (dupa `json_mapare` si canonicalizare). Permite corectarea
|
||||
unei valori sau completarea unui camp a carui coloana LIPSESTE din fisier, fara
|
||||
sa atinga `raw_json`/idempotency.
|
||||
`override`: patch CANONIC editat in preview, aplicat ULTIMUL peste valorile
|
||||
mapate (dupa `json_mapare` si canonicalizare). Permite corectarea unei valori
|
||||
sau completarea unui camp a carui coloana LIPSESTE din fisier, fara sa atinga
|
||||
`raw_json`/idempotency.
|
||||
"""
|
||||
# Aplica maparea de coloane
|
||||
mapped: dict[str, Any] = {}
|
||||
@@ -151,7 +150,7 @@ def _resolve_row_for_preview(
|
||||
if col_fisier in raw_row and camp_canonic:
|
||||
mapped[camp_canonic] = raw_row[col_fisier]
|
||||
|
||||
# Detectie coloane cu formule (Issue 3) — nu blocheaza, dar adauga flag
|
||||
# Detectie coloane cu formule — nu blocheaza, dar adauga flag
|
||||
formula_flag: list[str] = []
|
||||
for col_fisier, camp_canonic in json_mapare.items():
|
||||
if col_fisier in formula_columns:
|
||||
@@ -186,7 +185,7 @@ def _resolve_row_for_preview(
|
||||
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
||||
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
|
||||
|
||||
# Canonicalizare (T9): normalizeaza VIN/nr/odometru
|
||||
# Canonicalizare: normalizeaza VIN/nr/odometru
|
||||
canon = canonicalize_row(mapped)
|
||||
mapped.update({
|
||||
"vin": canon["vin"],
|
||||
@@ -194,7 +193,7 @@ def _resolve_row_for_preview(
|
||||
"odometru_final": canon["odometru_final"],
|
||||
})
|
||||
|
||||
# Override editat in preview (3.6) — aplicat ULTIMUL, peste valorile mapate +
|
||||
# Override editat in preview — aplicat ULTIMUL, peste valorile mapate +
|
||||
# canonicalizate. Valorile din override sunt deja canonice (vezi _merge_override).
|
||||
if override:
|
||||
mapped.update(override)
|
||||
@@ -230,7 +229,7 @@ def _resolve_row_for_preview(
|
||||
"flags": all_flags,
|
||||
}
|
||||
|
||||
# auto_send gate (T6/OV-1)
|
||||
# auto_send gate
|
||||
if has_no_auto_send(resolved, mapping_meta):
|
||||
return {
|
||||
"resolved_status": "needs_mapping",
|
||||
@@ -261,8 +260,8 @@ def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any]) ->
|
||||
return build_key(account_id, canon)
|
||||
|
||||
|
||||
# Campuri de continut editabile in preview (3.6). Operatia/codul RAR NU se editeaza
|
||||
# aici (raman in panoul de mapare) — vezi Non-Goals din PRD 3.6.
|
||||
# Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza
|
||||
# aici (raman in panoul de mapare).
|
||||
EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final")
|
||||
|
||||
|
||||
@@ -350,7 +349,7 @@ def apply_row_override(
|
||||
|
||||
|
||||
def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, dict]:
|
||||
"""Cauta cheile de idempotenta in submissions (batch, nu N+1 — Eng#5).
|
||||
"""Cauta cheile de idempotenta in submissions (batch, nu N+1).
|
||||
|
||||
Intoarce {idempotency_key: {id, id_prezentare, created_at}} pentru cheile gasite.
|
||||
"""
|
||||
@@ -371,7 +370,7 @@ def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, di
|
||||
"id_prezentare": r["id_prezentare"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
# Dual-lookup pentru chei legacy (OV-2: chei vechi cu account_id=None)
|
||||
# Dual-lookup pentru chei legacy (chei vechi cu account_id=None)
|
||||
legacy_keys_needed = [k for k in chunk if k not in found]
|
||||
if legacy_keys_needed:
|
||||
lph = ",".join("?" * len(legacy_keys_needed))
|
||||
@@ -403,8 +402,7 @@ async def upload_import(
|
||||
"""Upload fisier xlsx/csv -> staging in import_batches/import_rows.
|
||||
|
||||
Nu trimite nimic la RAR. Intoarce {import_id, columns, sample_rows, sheets?}.
|
||||
PII (raw_json) criptat Fernet la rest (Issue 5a).
|
||||
Scrieri bulk in tranzactie explicita (Issue 6).
|
||||
PII (raw_json) criptat Fernet la rest. Scrieri bulk in tranzactie explicita.
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
data = await file.read()
|
||||
@@ -468,7 +466,7 @@ async def upload_import(
|
||||
try:
|
||||
sig = _signature(parsed.columns)
|
||||
|
||||
# Issue 6: tranzactie explicita BEGIN IMMEDIATE + executemany
|
||||
# Tranzactie explicita BEGIN IMMEDIATE + executemany
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
# Insert import_batches
|
||||
@@ -482,7 +480,7 @@ async def upload_import(
|
||||
# Insert import_rows bulk (executemany) cu PII criptat
|
||||
row_params = []
|
||||
for i, row_dict in enumerate(parsed.rows):
|
||||
raw_json_enc = encrypt_creds(row_dict) # Criptat Fernet (Issue 5a)
|
||||
raw_json_enc = encrypt_creds(row_dict) # Criptat Fernet
|
||||
row_params.append((batch_id, i, raw_json_enc, "pending", None))
|
||||
|
||||
conn.executemany(
|
||||
@@ -506,11 +504,8 @@ async def upload_import(
|
||||
# Sample rows (primele 3, fara PII)
|
||||
sample = parsed.rows[:3]
|
||||
|
||||
# Persistam metadata parsedata (coercion_flags, date_col_format, formula_columns)
|
||||
# in import_batches pentru refolosire la preview (stocam ca JSON in 'status' nu e OK,
|
||||
# ci ca metadate suplimentare — le stocam intr-un rand separat sau returnam direct)
|
||||
# Solutie: le returnam in raspuns; preview-ul le va recalcula din raw_json deja stocat
|
||||
# SAU le stocam ca un camp extra. Cel mai simplu: stocam coloanele in batch.
|
||||
# Metadata parsata (coercion_flags etc.) se intoarce in raspuns; preview-ul
|
||||
# o recalculeaza din raw_json deja stocat.
|
||||
conn.execute(
|
||||
"UPDATE import_batches SET ok=?, needs_review=? WHERE id=?",
|
||||
(0, len(parsed.coercion_flags), batch_id),
|
||||
@@ -532,7 +527,7 @@ async def upload_import(
|
||||
result["column_mapping"] = json.loads(existing_mapping["json_mapare"])
|
||||
result["format_data"] = existing_mapping["format_data"]
|
||||
else:
|
||||
# Sugestii fuzzy per coloana (Issue 5b: refoloseste normalize_for_match)
|
||||
# Sugestii fuzzy per coloana
|
||||
suggestions: dict[str, list[dict]] = {}
|
||||
for col in parsed.columns:
|
||||
sugg = _fuzzy_suggest_column(col, limit=3)
|
||||
@@ -676,7 +671,7 @@ def save_column_mapping(
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# GET /v1/import/{id}/preview — 6 stari per rand (T2 + T11) #
|
||||
# GET /v1/import/{id}/preview — 6 stari per rand #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@router.get("/{import_id}/preview")
|
||||
@@ -686,8 +681,8 @@ def preview_import(
|
||||
) -> dict:
|
||||
"""Preview 6 stari per rand: ok/needs_mapping/needs_data/needs_review/already_sent/duplicate_in_file.
|
||||
|
||||
Nu enqueue-aza nimic. Already_sent = lookup batch (Eng#5). Duplicate_in_file = intra-batch
|
||||
collision (OV-3: EXCLUSIV aici, NU in reconcile.py/worker).
|
||||
Nu enqueue-aza nimic. Already_sent = lookup batch. Duplicate_in_file = intra-batch
|
||||
collision (EXCLUSIV aici, NU in reconcile.py/worker).
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
conn = get_connection()
|
||||
@@ -708,7 +703,7 @@ def preview_import(
|
||||
if not raw_rows_db:
|
||||
return {"rows": [], "summary": {}}
|
||||
|
||||
# Decripteaza si reconstruieste randurile + override-urile editate (3.6)
|
||||
# Decripteaza si reconstruieste randurile + override-urile editate
|
||||
rows: list[dict] = []
|
||||
overrides: list[dict] = []
|
||||
for r in raw_rows_db:
|
||||
@@ -747,22 +742,18 @@ def preview_import(
|
||||
json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"])
|
||||
format_data = mapping_row["format_data"]
|
||||
|
||||
# Incarca maparea de operatii o singura data (Eng#5: load_mapping o singura data)
|
||||
# Incarca maparea de operatii o singura data
|
||||
mapping_meta = load_mapping_meta(conn, acct)
|
||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||
# T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
||||
# Validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
||||
valid_codes = load_nomenclator_codes(conn) or None
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
|
||||
# Reconstruieste parsed info (coercion_flags si date_col_format) din datele stocate
|
||||
# Nota: import_rows stocheaza raw_json dupa coercion (din parse_file)
|
||||
# Recalculam flags din valorile stocate (coercion_flags nu e stocat separat)
|
||||
# Vom folosi o detectie simpla: VIN-uri care par numerice si odometru float
|
||||
# Recalculam coercion_flags din valorile stocate (nu sunt persistate separat):
|
||||
# detectie simpla de VIN numeric.
|
||||
coercion_flags_map: dict[int, list[str]] = {}
|
||||
# Detectam din valorile stocate
|
||||
for i, row_dict in enumerate(rows):
|
||||
flags = []
|
||||
# Detectam VIN numeric: daca valoarea a fost stocata si arata ca numar
|
||||
for col_f, camp_c in json_mapare.items():
|
||||
if camp_c == "vin":
|
||||
vin_val = row_dict.get(col_f)
|
||||
@@ -830,11 +821,11 @@ def preview_import(
|
||||
"idempotency_key": key,
|
||||
})
|
||||
|
||||
# Already_sent: batch lookup (Eng#5 — nu N+1)
|
||||
# Already_sent: batch lookup (nu N+1)
|
||||
unique_keys = list(set(keys_for_lookup))
|
||||
already_sent_map = _already_sent_lookup(conn, account_id, unique_keys)
|
||||
|
||||
# Duplicate_in_file (OV-3): detectie coliziuni intra-batch
|
||||
# Duplicate_in_file: detectie coliziuni intra-batch.
|
||||
# Grupam pe cheie de idempotenta: >1 rand cu aceeasi cheie = duplicate
|
||||
key_to_indices: dict[str, list[int]] = {}
|
||||
for row in preview_rows:
|
||||
@@ -857,7 +848,7 @@ def preview_import(
|
||||
row["already_sent_info"] = sent_info
|
||||
continue
|
||||
|
||||
# Duplicate_in_file (OV-3): >1 rand cu aceeasi cheie in ACELASI fisier
|
||||
# Duplicate_in_file: >1 rand cu aceeasi cheie in ACELASI fisier
|
||||
indices_with_same_key = key_to_indices.get(k, [])
|
||||
if len(indices_with_same_key) > 1 and row["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
||||
others = [idx for idx in indices_with_same_key if idx != row["row_index"]]
|
||||
@@ -911,7 +902,7 @@ def preview_import(
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# POST /v1/import/{id}/commit — gate HARD + enqueue + log atestare (T5+T12) #
|
||||
# POST /v1/import/{id}/commit — gate HARD + enqueue + log atestare #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class CommitIn(BaseModel):
|
||||
@@ -929,9 +920,9 @@ def commit_import(
|
||||
req: CommitIn,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
"""Gate HARD confirmare + enqueue randuri ok + log atestare (T5+T12).
|
||||
"""Gate HARD confirmare + enqueue randuri ok + log atestare.
|
||||
|
||||
TOCTOU (Issue 1): INSERT per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
|
||||
TOCTOU: INSERT per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
|
||||
Randuri colidante = reclasificate already_sent in rezultatul commit-ului.
|
||||
rows_hash + n_confirmed acopera DOAR randurile efectiv puse in coada.
|
||||
"""
|
||||
@@ -981,7 +972,7 @@ def commit_import(
|
||||
elif r["resolved_status"] == "needs_review":
|
||||
review_indices.add(r["row_index"])
|
||||
|
||||
# needs_review bifate explicit (Voce#1 — atestare pe valori)
|
||||
# needs_review bifate explicit (atestare pe valori)
|
||||
confirmed_review = [idx for idx in req.reviewed_rows if idx in review_indices]
|
||||
for idx in confirmed_review:
|
||||
# Gaseste randul needs_review si il adauga la ok_rows
|
||||
@@ -1040,7 +1031,7 @@ def commit_import(
|
||||
# Incarca maparea de operatii
|
||||
mapping_meta = load_mapping_meta(conn, acct)
|
||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||
# T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
||||
# Validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
||||
valid_codes = load_nomenclator_codes(conn) or None
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
|
||||
@@ -1049,10 +1040,9 @@ def commit_import(
|
||||
toctou_collisions: list[int] = []
|
||||
rows_for_hash: list[str] = []
|
||||
|
||||
# Enqueue in tranzactie explicita (Issue 6)
|
||||
# Enqueue in tranzactie explicita
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
# purge_after pentru submissions noi (T16)
|
||||
purge_after_sql = "datetime('now', '+90 days')"
|
||||
|
||||
for ok_row in ok_rows:
|
||||
@@ -1100,7 +1090,7 @@ def commit_import(
|
||||
"odometru_final": canon["odometru_final"],
|
||||
})
|
||||
|
||||
# Override editat in preview (3.6) — aplicat ULTIMUL, ca in resolver.
|
||||
# Override editat in preview — aplicat ULTIMUL, ca in resolver.
|
||||
override = ok_row.get("override") or {}
|
||||
if override:
|
||||
mapped.update(override)
|
||||
@@ -1127,7 +1117,7 @@ def commit_import(
|
||||
|
||||
payload_json = json.dumps(mapped, ensure_ascii=False)
|
||||
|
||||
# INSERT ON CONFLICT DO NOTHING (TOCTOU — Issue 1)
|
||||
# INSERT ON CONFLICT DO NOTHING (TOCTOU)
|
||||
cur = conn.execute(
|
||||
"INSERT OR IGNORE INTO submissions "
|
||||
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after) "
|
||||
@@ -1140,7 +1130,6 @@ def commit_import(
|
||||
toctou_collisions.append(row_index)
|
||||
else:
|
||||
sub_id = cur.lastrowid
|
||||
# US-010: telemetrie pentru itemii rezolvati prin regula text.
|
||||
_emite_text_rule_hits(conn, acct, int(sub_id), resolved)
|
||||
enqueued.append({
|
||||
"submission_id": sub_id,
|
||||
@@ -1155,7 +1144,7 @@ def commit_import(
|
||||
|
||||
n_enqueued = len(enqueued)
|
||||
|
||||
# Log atestare (Voce#9): rows_hash + n_confirmed acopera DOAR randurile puse in coada
|
||||
# Log atestare: rows_hash + n_confirmed acopera DOAR randurile puse in coada
|
||||
rows_hash = hashlib.sha256(
|
||||
json.dumps(sorted(rows_for_hash), ensure_ascii=False).encode("utf-8")
|
||||
).hexdigest() if rows_for_hash else ""
|
||||
@@ -1185,7 +1174,7 @@ def commit_import(
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# POST /v1/import/{id}/rand/{row_index}/editeaza — editare celule preview (3.6) #
|
||||
# POST /v1/import/{id}/rand/{row_index}/editeaza — editare celule preview #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class RandEditIn(BaseModel):
|
||||
@@ -1205,7 +1194,7 @@ def editeaza_rand(
|
||||
req: RandEditIn,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
"""Persista editarea unui rand de preview (mutatie pura — Approach B, 3.6).
|
||||
"""Persista editarea unui rand de preview (mutatie pura).
|
||||
|
||||
NU recalculeaza statusul si NU atinge `submissions`; preview-ul rederiva statusul
|
||||
prin `_resolve_row_for_preview` cu override aplicat ultimul.
|
||||
@@ -1225,7 +1214,7 @@ def editeaza_rand(
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# GET /v1/import/{id}/export-failed — CSV randuri esuate (T8) #
|
||||
# GET /v1/import/{id}/export-failed — CSV randuri esuate #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_EXPORT_FAILED_COLUMNS = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Router integrare US-001 — endpoint-uri de integrare externe.
|
||||
"""Router integrare — endpoint-uri de integrare externe.
|
||||
|
||||
Endpointuri:
|
||||
GET /v1/ping — readiness check per cont (autentificat sau dev fallback)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"""API v1 — suprafata gateway (schelet).
|
||||
"""API v1 — suprafata gateway.
|
||||
|
||||
Endpointuri din plan.md sect. 4. In schelet:
|
||||
Endpointuri:
|
||||
- POST /v1/prezentari: enqueue cu idempotenta (dedup pe idempotency_key UNIQUE).
|
||||
- GET /v1/prezentari, /v1/prezentari/{id}: monitorizare coada.
|
||||
- GET /v1/nomenclator: cache local.
|
||||
- GET /v1/mapari: listare mapari cont.
|
||||
Validarea completa (T3), maparea op->cod, auth API-key, redactarea creds in
|
||||
middleware (CORE) si exportul CSV vin ulterior — marcate TODO unde lipsesc.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -79,7 +77,7 @@ def _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, tex
|
||||
|
||||
|
||||
def _erori_nemapate(unmapped: list[dict]) -> list[dict]:
|
||||
"""Coduri nemapate imbogatite cu 3 niveluri (COD_NEMAPAT), pentru raspuns onest."""
|
||||
"""Coduri nemapate imbogatite cu 3 niveluri (COD_NEMAPAT)."""
|
||||
return [
|
||||
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} necunoscut/fara mapare RAR")}
|
||||
for u in unmapped
|
||||
@@ -87,7 +85,7 @@ def _erori_nemapate(unmapped: list[dict]) -> list[dict]:
|
||||
|
||||
|
||||
def _motiv_clasificare(cl: dict) -> str | None:
|
||||
"""Rezumat uman pe o linie pentru un rezultat de clasificare (PRD 5.7).
|
||||
"""Rezumat uman pe o linie pentru un rezultat de clasificare.
|
||||
|
||||
None cand status='queued'. Acopera toate ramurile de blocaj: erori de continut
|
||||
(needs_data), coduri nemapate (needs_mapping) si auto_send oprit (needs_mapping).
|
||||
@@ -107,7 +105,7 @@ def _motiv_clasificare(cl: dict) -> str | None:
|
||||
|
||||
|
||||
def _rezultat_enqueue(submission_id: int | None, cl: dict, **extra) -> SubmissionResult:
|
||||
"""SubmissionResult onest dintr-un rezultat de clasificare (PRD 5.7).
|
||||
"""SubmissionResult onest dintr-un rezultat de clasificare.
|
||||
|
||||
Populeaza erori (validare continut), nemapate (coduri fara mapare) si motiv (uman)
|
||||
pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None.
|
||||
@@ -141,42 +139,40 @@ def create_prezentari(
|
||||
) -> PrezentariResponse:
|
||||
"""Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission.
|
||||
|
||||
Validarea de continut (T3, app.validation) ruleaza inainte de enqueue:
|
||||
esecurile NU resping cererea, ci enqueue-aza cu status `needs_data` + motiv
|
||||
(plan.md sect. 3). JSON malformat -> 422 din Pydantic (validare de shape).
|
||||
Validarea de continut (app.validation) ruleaza inainte de enqueue: esecurile NU
|
||||
resping cererea, ci enqueue-aza cu status `needs_data` + motiv. JSON malformat ->
|
||||
422 din Pydantic (validare de shape).
|
||||
account_id vine din cheia API (resolve_account_id): cont real cu cheie,
|
||||
implicit id=1 in dev fara cheie, 401 fara cheie valida in prod.
|
||||
Nota: rar_credentials NU se persista (zero-storage) — worker-ul le va primi
|
||||
pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea.
|
||||
Cand rar_credentials lipseste, submission-ul intra fara creds efemere: worker-ul
|
||||
cade pe creds-urile durabile ale contului (`accounts.rar_creds_enc`).
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
# Creds RAR efemere: criptate si lipite de fiecare submission nou pana la
|
||||
# primul login reusit pentru cont (worker le sterge atunci). Zero-storage at
|
||||
# rest — niciodata in clar in DB/loguri (plan sect. 5). Optional: cand lipsesc,
|
||||
# rest — niciodata in clar in DB/loguri. Optional: cand lipsesc,
|
||||
# creds_enc=NULL si worker-ul foloseste creds-urile durabile ale contului.
|
||||
creds_enc = encrypt_creds(req.rar_credentials.model_dump()) if req.rar_credentials else None
|
||||
conn = get_connection()
|
||||
results: list[SubmissionResult] = []
|
||||
try:
|
||||
# T6/OV-1: load_mapping_meta include auto_send per op (gate pentru coduri noi).
|
||||
# load_mapping_meta include auto_send per op (gate pentru coduri noi).
|
||||
mapping_meta = load_mapping_meta(conn, acct)
|
||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||
# Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat.
|
||||
# valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot).
|
||||
valid_codes = load_nomenclator_codes(conn) or None
|
||||
# Reguli text incarcate o data per cerere (seam partajat cu dry-run, invariant 5.2).
|
||||
# Reguli text incarcate o data per cerere (seam partajat cu dry-run).
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
|
||||
for prez in req.prezentari:
|
||||
content = prez.model_dump()
|
||||
# T9/OV-2: canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).
|
||||
# canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).
|
||||
# build_key aplica account_or_default(account_id) inainte de hash:
|
||||
# None si 1 colapseaza la aceeasi cheie (canal API + canal import).
|
||||
canon = canonicalize_row(content)
|
||||
key = build_key(account_id, canon)
|
||||
# Aplica normalizarea si in content (odometru canonicalizat inainte de validare, §3.4bis)
|
||||
# Aplica normalizarea si in content (odometru canonicalizat inainte de validare)
|
||||
content.update({
|
||||
"vin": canon["vin"],
|
||||
"nr_inmatriculare": canon["nr_inmatriculare"],
|
||||
@@ -187,7 +183,7 @@ def create_prezentari(
|
||||
(key,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
# US-012: un rand `error` (ex. creds RAR gresite) NU mai blocheaza tacit
|
||||
# Un rand `error` (ex. creds RAR gresite) NU mai blocheaza tacit
|
||||
# retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam
|
||||
# creds + reset), printr-un UPDATE compare-and-swap pe status='error'.
|
||||
if existing["status"] == "error":
|
||||
@@ -205,17 +201,16 @@ def create_prezentari(
|
||||
cl["rar_error"], creds_enc, existing["id"]),
|
||||
)
|
||||
if cur.rowcount == 1:
|
||||
# Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc,
|
||||
# decizie #17) — ambele canale converg pe parola corectata.
|
||||
# Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc)
|
||||
# — ambele canale converg pe parola corectata.
|
||||
if req.rar_credentials is not None:
|
||||
conn.execute(
|
||||
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
|
||||
(encrypt_creds(req.rar_credentials.model_dump()), acct),
|
||||
)
|
||||
# US-010: telemetrie pentru itemii rezolvati prin regula text.
|
||||
_emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"])
|
||||
# Raspuns onest si la reactivare (PRD 5.7): daca re-clasificarea
|
||||
# cade pe needs_data/needs_mapping, expune motivul (nu doar status).
|
||||
# Raspuns onest si la reactivare: daca re-clasificarea cade pe
|
||||
# needs_data/needs_mapping, expune motivul (nu doar status).
|
||||
results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True))
|
||||
continue
|
||||
# Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE
|
||||
@@ -234,7 +229,7 @@ def create_prezentari(
|
||||
)
|
||||
continue
|
||||
|
||||
# Helper pur partajat cu dry-run (PRD 5.2): reproduce EXACT clasificarea
|
||||
# Helper pur partajat cu dry-run: reproduce EXACT clasificarea
|
||||
# (canonicalize + mapare op->cod + validare + auto_send gate).
|
||||
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
||||
if cl["blocked_error"]:
|
||||
@@ -247,13 +242,12 @@ def create_prezentari(
|
||||
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc),
|
||||
)
|
||||
sub_id = int(cur.lastrowid)
|
||||
# US-010: telemetrie pentru itemii rezolvati prin regula text.
|
||||
_emite_text_rule_hits(conn, acct, sub_id, cl["resolved"])
|
||||
# Raspuns onest (PRD 5.7): pe needs_data/needs_mapping expune erori/nemapate/motiv.
|
||||
# Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv.
|
||||
results.append(_rezultat_enqueue(sub_id, cl))
|
||||
|
||||
# US-004: audit cerere API per cont. Doar metadate (count + distributie status),
|
||||
# NICIUN camp de payload PII integral. Reuse conn (T4 — fara contentie WAL).
|
||||
# Audit cerere API per cont. Doar metadate (count + distributie status),
|
||||
# NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL).
|
||||
dist: dict[str, int] = {}
|
||||
for r in results:
|
||||
if r.reactivated:
|
||||
@@ -284,7 +278,7 @@ def valideaza_prezentari(
|
||||
|
||||
Intoarce pentru fiecare prezentare: verdictul (status_estimat), erorile de
|
||||
continut si codurile nemapate — exact ce ar obtine trimiterea reala pe acelasi
|
||||
payload + aceeasi mapare de cont. rar_credentials ignorat complet (PRD 5.2).
|
||||
payload + aceeasi mapare de cont. rar_credentials ignorat complet.
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
conn = get_connection()
|
||||
@@ -301,7 +295,7 @@ def valideaza_prezentari(
|
||||
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
||||
if res["blocked_error"]:
|
||||
res = {**res, "status": "error"}
|
||||
# US-003: imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
|
||||
# Imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
|
||||
nemapate = [
|
||||
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} fara mapare RAR")}
|
||||
for u in res["unmapped"]
|
||||
@@ -329,7 +323,7 @@ def list_prezentari(
|
||||
try:
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
# payload_json e plaintext (vezi submissions.payload_json); il citim doar ca
|
||||
# sa derivam campurile afisabile prin helper-ul partajat (US-003, DRY), nu il expunem.
|
||||
# sa derivam campurile afisabile prin helper-ul partajat, nu il expunem.
|
||||
cols = (
|
||||
"id, status, id_prezentare, rar_status_code, retry_count, "
|
||||
"created_at, updated_at, payload_json"
|
||||
@@ -357,13 +351,13 @@ def list_prezentari(
|
||||
conn.close()
|
||||
|
||||
|
||||
# Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita (B4).
|
||||
# Exclude: rar_creds_enc, payload_json, idempotency_key, rar_error, sending_since.
|
||||
# Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita.
|
||||
# Exclude: rar_creds_enc, payload_json, idempotency_key, sending_since.
|
||||
_PREZENTARE_FIELDS = frozenset({
|
||||
"id", "status", "id_prezentare", "rar_status_code", "retry_count",
|
||||
"next_attempt_at", "created_at", "updated_at", "account_id",
|
||||
"batch_id", "row_index", "purge_after",
|
||||
# T9: rar_error e SIGUR de expus — contine doar coduri/mesaje de validare RAR si
|
||||
# rar_error e SIGUR de expus — contine doar coduri/mesaje de validare RAR si
|
||||
# erori din catalog (niciodata creds, ex. RAR_CREDS_INVALIDE poarta doar cauza
|
||||
# "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API.
|
||||
"rar_error",
|
||||
@@ -383,7 +377,7 @@ def get_prezentare(
|
||||
[submission_id] + scope_params,
|
||||
).fetchone()
|
||||
if not row:
|
||||
# B3: acelasi mesaj indiferent daca randul exista dar apartine altui cont
|
||||
# Acelasi mesaj indiferent daca randul exista dar apartine altui cont
|
||||
# sau nu exista deloc — nu confirmam existenta.
|
||||
raise HTTPException(status_code=404, detail="submission inexistent")
|
||||
row_dict = dict(row)
|
||||
@@ -397,11 +391,11 @@ def delete_prezentare(
|
||||
submission_id: int,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
"""Sterge o trimitere blocata a contului cheii API (US-010).
|
||||
"""Sterge o trimitere blocata a contului cheii API.
|
||||
|
||||
Raspuns 200 + body JSON (NU 204 — clienti VFP fac string-parse). Scope evaluat
|
||||
INAINTEA starii (decizie /autoplan #20): cross-account / inexistent -> 404 (acelasi
|
||||
mesaj, B3); own-account `sent`/`sending` -> 409 (conflict de stare).
|
||||
INAINTEA starii: cross-account / inexistent -> 404 (acelasi mesaj);
|
||||
own-account `sent`/`sending` -> 409 (conflict de stare).
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
@@ -424,10 +418,10 @@ def repune_prezentare(
|
||||
submission_id: int,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
"""Re-pune in coada o trimitere blocata a contului cheii API (US-010).
|
||||
"""Re-pune in coada o trimitere blocata a contului cheii API.
|
||||
|
||||
`error -> queued` (peste helper US-009), re-ruleaza classify. Acelasi oracol de
|
||||
scope/stare ca DELETE (404 cross-account/inexistent, 409 sent/sending).
|
||||
`error -> queued`, re-ruleaza classify. Acelasi oracol de scope/stare ca DELETE
|
||||
(404 cross-account/inexistent, 409 sent/sending).
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
@@ -478,8 +472,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a
|
||||
"""Randuri audit filtrate pe cont + data(updated_at) in [from, to].
|
||||
|
||||
account_id = contul cheii API (scope obligatoriu — PII in CSV). Randuri cu
|
||||
account_id IS NULL apartin contului 1 (legacy/OV-2). payload_json e text in
|
||||
schelet; b64_image NU intra in CSV.
|
||||
account_id IS NULL apartin contului 1. b64_image NU intra in CSV.
|
||||
"""
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
sql = (
|
||||
@@ -514,7 +507,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a
|
||||
"submission_id": r["id"],
|
||||
"status": r["status"],
|
||||
"id_prezentare": r["id_prezentare"] or "",
|
||||
# NULL→cont 1 (OV-2): coloana reflecta invariantul de scope, nu "" ambiguu.
|
||||
# NULL→cont 1: coloana reflecta invariantul de scope, nu "" ambiguu.
|
||||
"account_id": account_or_default(r["account_id"]),
|
||||
"vin": p.get("vin") or "",
|
||||
"nr_inmatriculare": p.get("nr_inmatriculare") or "",
|
||||
@@ -539,7 +532,7 @@ def audit_export(
|
||||
|
||||
pe data(updated_at). `status` implicit `sent` (ce a ajuns efectiv la RAR);
|
||||
`status=all` exporta toata coada contului. Leaga retinerea 90 zile prin coloana
|
||||
`purge_after` (plan.md sect. 4 + 8). b64_image nu se exporta.
|
||||
`purge_after`. b64_image nu se exporta.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
@@ -568,7 +561,7 @@ def get_mapari(
|
||||
"""Maparile operatie->cod ale contului curent.
|
||||
|
||||
Parametrul `account_id` din query e pastrat pentru compatibilitate, dar contul
|
||||
efectiv vine MEREU din cheia API (TD-3.2). Daca e prezent si difera -> 400.
|
||||
efectiv vine MEREU din cheia API. Daca e prezent si difera -> 400.
|
||||
"""
|
||||
if account_id is not None and account_id != key_account:
|
||||
raise HTTPException(
|
||||
@@ -635,7 +628,7 @@ def create_mapare(
|
||||
|
||||
|
||||
class RarCredsIn(BaseModel):
|
||||
"""Creds RAR durabile per-cont (D4). Stocate criptate (Fernet) in accounts.rar_creds_enc."""
|
||||
"""Creds RAR durabile per-cont. Stocate criptate (Fernet) in accounts.rar_creds_enc."""
|
||||
|
||||
email: str = Field(..., min_length=1)
|
||||
password: str = Field(..., min_length=1, repr=False)
|
||||
@@ -646,7 +639,7 @@ def set_rar_creds(
|
||||
req: RarCredsIn,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
"""Seteaza creds RAR durabile per-cont (D4/T1).
|
||||
"""Seteaza creds RAR durabile per-cont.
|
||||
|
||||
Criptate Fernet in accounts.rar_creds_enc. Worker-ul le foloseste ca fallback
|
||||
cand submission-ul nu mai are creds (canal web fara re-pusher, restart worker).
|
||||
|
||||
Reference in New Issue
Block a user