Files
rar-autopass/app/api/v1/router.py
Claude Agent a6df3b636f feat(T5): editor web mapare operatii (hibrid + fuzzy + on-demand needs_mapping)
T5 reinterpretat: nu import DBF, ci editor web al maparii operatie ROAAUTO ->
cod RAR, cu fuzzy lookup si validare de catre utilizator.

- Contract hibrid: item prestatie accepta cod_prestatie (RAR direct, back-compat)
  SAU cod_op_service+denumire (mapat de gateway prin operations_mapping).
- Ingestie: op intern necunoscut -> submission needs_mapping (nu pleaca la RAR);
  codul rezolvat se scrie inapoi in payload_json -> payload builder + worker neatinse.
- Editor HTMX (_mapari.html + GET /_fragments/mapari, POST /mapari): listeaza
  op-urile nemapate, fuzzy preselecteaza codul RAR, save -> re-rezolvare automata
  (queued / needs_data).
- Fuzzy: rapidfuzz.token_sort_ratio pe denumire normalizata (fara diacritice).
- Nomenclator: seed fallback 18 coduri la boot (offline) + refresh live din worker.
- Cont default id=1 cat timp auth API-key (CORE) nu exista (account_id NULL).
- Endpointuri API: GET /v1/mapari/pending, POST /v1/mapari (respinge cod inexistent).
- 15 teste noi (tests/test_mapping.py); 69 pass total.
- Contract actualizat (docs/api-rar-contract.md), rapidfuzz==3.14.5 in requirements.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:25:21 +00:00

203 lines
7.6 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 json
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from ...db import get_connection
from ...idempotency import 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) -> 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).
TODO(auth): rezolva account_id din API key (acum None).
Nota: rar_credentials NU se persista (zero-storage) — worker-ul le va primi
pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea.
"""
account_id = None # TODO(auth): din API key
acct = account_or_default(account_id)
conn = get_connection()
results: list[SubmissionResult] = []
try:
mapping = load_mapping(conn, acct)
for prez in req.prezentari:
content = prez.model_dump()
key = idempotency_key(account_id, content)
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) "
"VALUES (?, ?, ?, ?, ?)",
(key, acct, status, json.dumps(content, ensure_ascii=False), rar_error),
)
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()
@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):
account_id: int | None = None
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) -> dict:
"""Salveaza/actualizeaza o mapare op->cod si re-rezolva submission-urile blocate.
Verifica intai ca `cod_prestatie` exista in nomenclator (nu lasam mapari catre
coduri inexistente). Apoi upsert + re-rezolvare automata a `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, req.account_id, req.cod_op_service, cod, req.auto_send)
stats = reresolve_account(conn, req.account_id)
return {"saved": {"cod_op_service": req.cod_op_service.strip(), "cod_prestatie": cod}, "reresolve": stats}
finally:
conn.close()