feat(api): endpoint dry-run POST /v1/prezentari/valideaza (PRD 5.2)

Valideaza payload + mapare si intoarce verdictul real (status_estimat
queued/needs_data/needs_mapping + erori [{field,message}] + coduri nemapate
+ prestatii rezolvate) FARA enqueue, fara creds, zero scriere DB. "Magical
moment" pentru integratori (ROAAUTO / soft propriu / punte VFP).

Cheia de design: helper pur partajat classify_prezentare (mapping.py) folosit
de AMBELE rute, ca dry-run-ul sa nu poata diverge de trimiterea reala
(invariant de corectitudine). create_prezentari refactorizat pe el cu
comportament identic (test_api.py verde).

Scope minim (decizie user): doar validare+mapare, fara idempotency/duplicat
(idempotency.py neatins); descoperibilitate in hub /integrare amanata.

VERIFY context curat PASS (577 teste; E2E API cu cele 3 verdicte + COUNT(*)=0
dupa dry-run). /code-review high: 0 findings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-22 18:54:50 +00:00
parent f0786051f5
commit ae7960294f
6 changed files with 445 additions and 35 deletions

View File

@@ -22,20 +22,25 @@ from pydantic import BaseModel, Field
from ...auth import resolve_account_id
from ...crypto import encrypt_creds
from ...db import get_connection
from ...idempotency import build_key, canonicalize_row, idempotency_key
from ...idempotency import build_key, canonicalize_row
from ...mapping import (
account_or_default,
account_scope_clause,
has_no_auto_send,
classify_prezentare,
load_mapping_meta,
pending_unmapped,
reresolve_account,
resolve_prestatii,
save_mapping,
)
from ...models import PrezentareRequest, PrezentariResponse, SubmissionResult
from ...models import (
PrezentareRequest,
PrezentariResponse,
SubmissionResult,
ValidarePrezentariRequest,
ValidareResponse,
ValidareResult,
)
from ...payload_view import prezentare_din_payload
from ...validation import validate_prezentare
router = APIRouter(prefix="/v1", tags=["v1"])
@@ -94,43 +99,53 @@ def create_prezentari(
)
continue
# Mapare op->cod RAR (hibrid): codul RAR direct trece neatins; codul
# intern ROAAUTO se traduce. Op nemapata -> needs_mapping (nu se trimite),
# apare in editorul web. Codul rezolvat se scrie inapoi in payload, deci
# validarea T3 + payload builder + worker raman code-driven.
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping)
content["prestatii"] = resolved
if unmapped:
status = "needs_mapping"
rar_error = json.dumps({"unmapped": unmapped}, ensure_ascii=False)
else:
# T3: validare de continut -> queued daca e curat, altfel needs_data + motiv.
errors = validate_prezentare(content)
if errors:
status, rar_error = "needs_data", json.dumps(errors, ensure_ascii=False)
elif has_no_auto_send(resolved, mapping_meta):
# T6/OV-1: cod rezolvat cu auto_send=0 -> nu trimite automat.
# Randul ramane 'needs_mapping' pana userul confirma manual (sau comuta auto_send=1).
status = "needs_mapping"
rar_error = json.dumps(
{"auto_send": "cod mapat cu auto_send=0; review manual inainte de trimitere"},
ensure_ascii=False,
)
else:
status, rar_error = "queued", None
# Helper pur partajat cu dry-run (PRD 5.2): reproduce EXACT clasificarea
# (canonicalize + mapare op->cod + validare + auto_send gate).
cl = classify_prezentare(content, mapping, mapping_meta)
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc) "
"VALUES (?, ?, ?, ?, ?, ?)",
(key, acct, status, json.dumps(content, ensure_ascii=False), rar_error, creds_enc),
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc),
)
results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=status))
results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=cl["status"]))
finally:
conn.close()
return PrezentariResponse(results=results)
@router.post("/prezentari/valideaza", response_model=ValidareResponse)
def valideaza_prezentari(
req: ValidarePrezentariRequest,
account_id: int = Depends(resolve_account_id),
) -> ValidareResponse:
"""Dry-run: valideaza payload exact ca POST /prezentari, fara enqueue si fara efecte secundare.
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).
"""
acct = account_or_default(account_id)
conn = get_connection()
results: list[ValidareResult] = []
try:
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
for i, prez in enumerate(req.prezentari):
content = prez.model_dump()
res = classify_prezentare(content, mapping, mapping_meta)
results.append(ValidareResult(
index=i,
valid=(res["status"] == "queued"),
status_estimat=res["status"],
erori=res["errors"],
nemapate=res["unmapped"],
prestatii_rezolvate=res["resolved"],
))
finally:
conn.close()
return ValidareResponse(results=results)
@router.get("/prezentari")
def list_prezentari(
status: str | None = None,