Inchide bucla de trimitere (plan.md sect. 4 worker, failure registry).
- app/reconcile.py: match_finalizata pe vin+dataPrestatie+odometruFinal (int),
alege id maxim la duplicate (RAR accepta duplicate, confirmat live)
- app/rar_client.get_finalizate: parseaza data.content (descoperit live ca
ruta = GET /prezentari/getAllPrezentariFinalizate; filtrele nu merg pe test)
- app/worker rescris:
- recuperare orfane (rand 'sending' peste lease = worker mort mid-POST)
- pe eroare tranzitorie/timeout: reconciliere INAINTE de re-send (anti-duplicat);
daca recordul exista la RAR -> sent fara re-POST
- retry/backoff exponential; peste worker_max_retries -> error + banner
- re-login la token expirat (JWT 30h)
- schema: coloana next_attempt_at (backoff) + migrare aditiva in init_db
- config: worker_sending_lease_s, worker_retry_base_s/max_s, worker_max_retries
- contract: documentata ruta+forma getAllPrezentariFinalizate (verificat live)
Verify: pytest 54 passed (15 noi T2) + validare live (reconciliere record 68514).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
139 lines
5.4 KiB
Python
139 lines
5.4 KiB
Python
"""Client RAR AUTOPASS — portare din rar_autopass.prg / rar-forms.prg.
|
|
|
|
Sursa de adevar pentru contract: docs/api-rar-contract.md (verificat live 2026-06-15).
|
|
Reguli care guverneaza acest client:
|
|
- TOATE apelurile trimit header User-Agent (altfel WAF da 403).
|
|
- login -> JWT (TTL 30h); token-ul se ataseaza ca `Authorization: Bearer`.
|
|
- postPrezentare: status mereu "FINALIZATA"; NU se trimite tipPrestatie.
|
|
- nomenclator: GET /nomenclator/getNomenclatorPrestatii (NU getPrestatiiNom -> 403).
|
|
- eroare validare RAR: HTTP 400, data = listă [{field, message}] (NU data.message).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from .config import Settings, get_settings
|
|
|
|
|
|
class RarError(Exception):
|
|
"""Eroare la apel RAR. `status_code` = HTTP RAR; `field_errors` = lista [{field,message}] la 400."""
|
|
|
|
def __init__(self, message: str, *, status_code: int | None = None, field_errors: list[dict] | None = None):
|
|
super().__init__(message)
|
|
self.status_code = status_code
|
|
self.field_errors = field_errors or []
|
|
|
|
|
|
class RarAuthError(RarError):
|
|
"""Login esuat (401 / credentiale invalide). NU se face retry."""
|
|
|
|
|
|
class RarClient:
|
|
"""Client sincron httpx. Folosit din worker (proces separat).
|
|
|
|
Utilizare:
|
|
with RarClient() as rar:
|
|
token = rar.login(email, password)
|
|
data = rar.post_prezentare(token, payload)
|
|
"""
|
|
|
|
def __init__(self, settings: Settings | None = None):
|
|
self.settings = settings or get_settings()
|
|
self._client = httpx.Client(
|
|
base_url=self.settings.rar_base_url,
|
|
timeout=self.settings.http_timeout_s,
|
|
headers={"User-Agent": self.settings.http_user_agent}, # fix WAF 403
|
|
)
|
|
|
|
def __enter__(self) -> "RarClient":
|
|
return self
|
|
|
|
def __exit__(self, *exc: object) -> None:
|
|
self.close()
|
|
|
|
def close(self) -> None:
|
|
self._client.close()
|
|
|
|
# --- Autentificare ---
|
|
|
|
def login(self, email: str, password: str) -> str:
|
|
"""POST /public/login -> JWT (str). Ridica RarAuthError la 401."""
|
|
resp = self._client.post("/public/login", json={"email": email, "password": password})
|
|
if resp.status_code == 401:
|
|
raise RarAuthError("Credentiale RAR invalide", status_code=401)
|
|
if resp.status_code != 200:
|
|
raise RarError(f"Login esuat (HTTP {resp.status_code})", status_code=resp.status_code)
|
|
token = resp.json().get("token")
|
|
if not token:
|
|
raise RarError("Login fara token in raspuns", status_code=resp.status_code)
|
|
return token
|
|
|
|
# --- Nomenclator ---
|
|
|
|
def get_nomenclator(self, token: str) -> list[dict]:
|
|
"""GET /nomenclator/getNomenclatorPrestatii -> listă coduri prestatii."""
|
|
resp = self._client.get(
|
|
"/nomenclator/getNomenclatorPrestatii",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
if resp.status_code != 200:
|
|
raise RarError(f"Nomenclator esuat (HTTP {resp.status_code})", status_code=resp.status_code)
|
|
data = resp.json()
|
|
# Raspunsul poate fi listă directa sau {data: [...]}; normalizam.
|
|
return data.get("data", data) if isinstance(data, dict) else data
|
|
|
|
# --- Prezentari ---
|
|
|
|
def post_prezentare(self, token: str, payload: dict[str, Any]) -> dict:
|
|
"""POST /prezentari/postPrezentare. Intoarce `data` (obiect) la succes.
|
|
|
|
La 400 (validare) ridica RarError cu field_errors din `data` (listă).
|
|
Apelantul NU trebuie sa includa tipPrestatie; status trebuie "FINALIZATA".
|
|
"""
|
|
resp = self._client.post(
|
|
"/prezentari/postPrezentare",
|
|
json=payload,
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
body = _safe_json(resp)
|
|
if resp.status_code == 200:
|
|
return body.get("data", {}) if isinstance(body, dict) else {}
|
|
if resp.status_code == 400 and isinstance(body, dict):
|
|
errors = body.get("data") if isinstance(body.get("data"), list) else []
|
|
msg = body.get("message", "Validare esuata la RAR")
|
|
raise RarError(msg, status_code=400, field_errors=errors)
|
|
raise RarError(f"postPrezentare esuat (HTTP {resp.status_code})", status_code=resp.status_code)
|
|
|
|
def get_finalizate(self, token: str) -> list[dict]:
|
|
"""Lista prezentarilor finalizate (pentru reconciliere — T2).
|
|
|
|
GET /prezentari/getAllPrezentariFinalizate -> data.content (listă).
|
|
Verificat live: filtrele/paginarea NU functioneaza pe test (vezi contract),
|
|
deci interogam fara parametri si filtram client-side. Pe test `prestatii`
|
|
vine null in fiecare item — match-ul se face pe vin+dataPrestatie+odometruFinal.
|
|
"""
|
|
resp = self._client.get(
|
|
"/prezentari/getAllPrezentariFinalizate",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
if resp.status_code != 200:
|
|
raise RarError(f"getFinalizate esuat (HTTP {resp.status_code})", status_code=resp.status_code)
|
|
body = _safe_json(resp)
|
|
if isinstance(body, dict):
|
|
data = body.get("data")
|
|
if isinstance(data, dict) and isinstance(data.get("content"), list):
|
|
return data["content"]
|
|
if isinstance(data, list):
|
|
return data
|
|
return []
|
|
|
|
|
|
def _safe_json(resp: httpx.Response) -> Any:
|
|
try:
|
|
return resp.json()
|
|
except ValueError:
|
|
return {"message": resp.text}
|