- canonicalize_row: VIN upper, odometru strip ".0" (Excel float coercion), data strip — INAINTE de validare si cheie (§3.4bis) - build_key: aplica account_or_default(None->1) inainte de hash (OV-2): canal API (None) si canal import (1) produc aceeasi cheie - build_key_legacy: helper dual-lookup pentru randuri DB vechi (pre-T9) - router.py: POST /v1/prezentari foloseste build_key(account_id, canonicalize_row(content)) - 14 teste: canonicalizare, cross-canal, dedup float/int odometru, legacy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
326 lines
12 KiB
Python
326 lines
12 KiB
Python
"""API v1 — suprafata gateway (schelet).
|
|
|
|
Endpointuri din plan.md sect. 4. In schelet:
|
|
- 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
|
|
|
|
import csv
|
|
import io
|
|
import json
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from fastapi.responses import StreamingResponse
|
|
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 ...mapping import (
|
|
account_or_default,
|
|
load_mapping,
|
|
pending_unmapped,
|
|
reresolve_account,
|
|
resolve_prestatii,
|
|
save_mapping,
|
|
)
|
|
from ...models import PrezentareRequest, PrezentariResponse, SubmissionResult
|
|
from ...validation import validate_prezentare
|
|
|
|
router = APIRouter(prefix="/v1", tags=["v1"])
|
|
|
|
|
|
@router.post("/prezentari", response_model=PrezentariResponse)
|
|
def create_prezentari(
|
|
req: PrezentareRequest,
|
|
account_id: int = Depends(resolve_account_id),
|
|
) -> 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).
|
|
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.
|
|
"""
|
|
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).
|
|
creds_enc = encrypt_creds(req.rar_credentials.model_dump())
|
|
conn = get_connection()
|
|
results: list[SubmissionResult] = []
|
|
try:
|
|
mapping = load_mapping(conn, acct)
|
|
for prez in req.prezentari:
|
|
content = prez.model_dump()
|
|
# T9/OV-2: 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)
|
|
content.update({
|
|
"vin": canon["vin"],
|
|
"nr_inmatriculare": canon["nr_inmatriculare"],
|
|
"odometru_final": canon["odometru_final"],
|
|
})
|
|
existing = conn.execute(
|
|
"SELECT id, status, id_prezentare FROM submissions WHERE idempotency_key=?",
|
|
(key,),
|
|
).fetchone()
|
|
if existing:
|
|
results.append(
|
|
SubmissionResult(
|
|
submission_id=existing["id"],
|
|
status=existing["status"],
|
|
id_prezentare=existing["id_prezentare"],
|
|
deduped=True,
|
|
)
|
|
)
|
|
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)
|
|
else:
|
|
status, rar_error = "queued", None
|
|
|
|
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),
|
|
)
|
|
results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=status))
|
|
finally:
|
|
conn.close()
|
|
return PrezentariResponse(results=results)
|
|
|
|
|
|
@router.get("/prezentari")
|
|
def list_prezentari(status: str | None = None, limit: int = 100) -> dict:
|
|
conn = get_connection()
|
|
try:
|
|
if status:
|
|
rows = conn.execute(
|
|
"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
|
|
"FROM submissions WHERE status=? ORDER BY id DESC LIMIT ?",
|
|
(status, limit),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
|
|
"FROM submissions ORDER BY id DESC LIMIT ?",
|
|
(limit,),
|
|
).fetchall()
|
|
return {"submissions": [dict(r) for r in rows]}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/prezentari/{submission_id}")
|
|
def get_prezentare(submission_id: int) -> dict:
|
|
conn = get_connection()
|
|
try:
|
|
row = conn.execute("SELECT * FROM submissions WHERE id=?", (submission_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="submission inexistent")
|
|
out = dict(row)
|
|
out.pop("payload_json", None) # nu expunem payload-ul brut (PII) in listare
|
|
return out
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/nomenclator")
|
|
def get_nomenclator() -> dict:
|
|
conn = get_connection()
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT cod_prestatie, nume_prestatie, updated_at FROM nomenclator_rar ORDER BY cod_prestatie"
|
|
).fetchall()
|
|
return {"nomenclator": [dict(r) for r in rows]}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
AUDIT_COLUMNS = [
|
|
"submission_id",
|
|
"status",
|
|
"id_prezentare",
|
|
"account_id",
|
|
"vin",
|
|
"nr_inmatriculare",
|
|
"data_prestatie",
|
|
"odometru_final",
|
|
"prestatii",
|
|
"rar_status_code",
|
|
"created_at",
|
|
"updated_at",
|
|
"purge_after",
|
|
]
|
|
|
|
|
|
def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str):
|
|
"""Randuri audit (sent implicit) filtrate pe data(updated_at) in [from, to].
|
|
|
|
payload_json e text in schelet (criptarea PII e P2); citim campurile-cheie
|
|
pentru audit. b64_image NU intra in CSV (mare). Daca P2 cripteaza payload-ul,
|
|
aici se decripteaza inainte de a construi randul.
|
|
"""
|
|
sql = "SELECT id, status, id_prezentare, account_id, payload_json, rar_status_code, created_at, updated_at, purge_after FROM submissions"
|
|
where = []
|
|
params: list = []
|
|
if status != "all":
|
|
where.append("status=?")
|
|
params.append(status)
|
|
if date_from:
|
|
where.append("date(updated_at) >= date(?)")
|
|
params.append(date_from)
|
|
if date_to:
|
|
where.append("date(updated_at) <= date(?)")
|
|
params.append(date_to)
|
|
if where:
|
|
sql += " WHERE " + " AND ".join(where)
|
|
sql += " ORDER BY id"
|
|
|
|
for r in conn.execute(sql, params).fetchall():
|
|
try:
|
|
p = json.loads(r["payload_json"]) if r["payload_json"] else {}
|
|
except (ValueError, TypeError):
|
|
p = {}
|
|
codes = ",".join(
|
|
(it.get("cod_prestatie") or it.get("cod_op_service") or "")
|
|
for it in (p.get("prestatii") or [])
|
|
if isinstance(it, dict)
|
|
)
|
|
yield {
|
|
"submission_id": r["id"],
|
|
"status": r["status"],
|
|
"id_prezentare": r["id_prezentare"] or "",
|
|
"account_id": r["account_id"] or "",
|
|
"vin": p.get("vin") or "",
|
|
"nr_inmatriculare": p.get("nr_inmatriculare") or "",
|
|
"data_prestatie": p.get("data_prestatie") or "",
|
|
"odometru_final": p.get("odometru_final") or "",
|
|
"prestatii": codes,
|
|
"rar_status_code": r["rar_status_code"] or "",
|
|
"created_at": r["created_at"],
|
|
"updated_at": r["updated_at"],
|
|
"purge_after": r["purge_after"] or "",
|
|
}
|
|
|
|
|
|
@router.get("/audit/export")
|
|
def audit_export(
|
|
date_from: str | None = None,
|
|
date_to: str | None = None,
|
|
status: str = "sent",
|
|
) -> StreamingResponse:
|
|
"""CSV cu ce s-a trimis (audit). Filtre optionale `date_from`/`date_to` (YYYY-MM-DD)
|
|
|
|
pe data(updated_at). `status` implicit `sent` (ce a ajuns efectiv la RAR);
|
|
`status=all` exporta toata coada. Leaga re_tinerea 90 zile prin coloana
|
|
`purge_after` (plan.md sect. 4 + 8). b64_image nu se exporta.
|
|
"""
|
|
conn = get_connection()
|
|
try:
|
|
buf = io.StringIO()
|
|
writer = csv.DictWriter(buf, fieldnames=AUDIT_COLUMNS)
|
|
writer.writeheader()
|
|
for row in _audit_rows(conn, date_from, date_to, status):
|
|
writer.writerow(row)
|
|
data = buf.getvalue()
|
|
finally:
|
|
conn.close()
|
|
|
|
fname = f"audit_{status}_{date_from or 'inceput'}_{date_to or 'azi'}.csv"
|
|
return StreamingResponse(
|
|
iter([data]),
|
|
media_type="text/csv",
|
|
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
|
)
|
|
|
|
|
|
@router.get("/mapari")
|
|
def get_mapari(account_id: int | None = None) -> dict:
|
|
conn = get_connection()
|
|
try:
|
|
if account_id is not None:
|
|
rows = conn.execute(
|
|
"SELECT * FROM operations_mapping WHERE account_id=? ORDER BY cod_op_service",
|
|
(account_id,),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute("SELECT * FROM operations_mapping ORDER BY account_id, cod_op_service").fetchall()
|
|
return {"mapari": [dict(r) for r in rows]}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/mapari/pending")
|
|
def get_mapari_pending() -> dict:
|
|
"""Operatii ROAAUTO nemapate (din submission-uri needs_mapping) + sugestii fuzzy.
|
|
|
|
Alimenteaza editorul web. Fiecare intrare: {account_id, cod_op_service, denumire,
|
|
blocked, suggestions:[{cod_prestatie, nume_prestatie, score}]}.
|
|
"""
|
|
conn = get_connection()
|
|
try:
|
|
return {"pending": pending_unmapped(conn)}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
class MapareIn(BaseModel):
|
|
cod_op_service: str = Field(..., min_length=1)
|
|
cod_prestatie: str = Field(..., min_length=1)
|
|
auto_send: bool = True
|
|
|
|
|
|
@router.post("/mapari")
|
|
def create_mapare(
|
|
req: MapareIn,
|
|
account_id: int = Depends(resolve_account_id),
|
|
) -> dict:
|
|
"""Salveaza/actualizeaza o mapare op->cod si re-rezolva submission-urile blocate.
|
|
|
|
Contul vine din cheia API (NU din body) — un cont nu poate edita maparile
|
|
altuia. Verifica intai ca `cod_prestatie` exista in nomenclator (nu lasam
|
|
mapari catre coduri inexistente). Apoi upsert + re-rezolvare `needs_mapping`.
|
|
"""
|
|
conn = get_connection()
|
|
try:
|
|
cod = req.cod_prestatie.strip().upper()
|
|
exists = conn.execute(
|
|
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
|
|
).fetchone()
|
|
if not exists:
|
|
raise HTTPException(status_code=422, detail=f"cod_prestatie '{cod}' nu exista in nomenclator")
|
|
save_mapping(conn, account_id, req.cod_op_service, cod, req.auto_send)
|
|
stats = reresolve_account(conn, account_id)
|
|
return {"saved": {"cod_op_service": req.cod_op_service.strip(), "cod_prestatie": cod}, "reresolve": stats}
|
|
finally:
|
|
conn.close()
|