"""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. `rar_message` = mesajul din envelope-ul de eroare al RAR (`{statusCode, message, data}`), cand exista. Prezenta lui pe un 5xx inseamna ca RAR A RASPUNS definitiv „am esuat" (nu o pierdere de raspuns) -> worker-ul il trateaza ca permanent, nu reconciliaza. """ def __init__( self, message: str, *, status_code: int | None = None, field_errors: list[dict] | None = None, rar_message: str | None = None, ): super().__init__(message) self.status_code = status_code self.field_errors = field_errors or [] self.rar_message = rar_message 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) # Non-200/non-400: pastram mesajul din envelope-ul RAR daca exista (ex. 500 cu # `{"statusCode":500,"message":"Eroare la adaugarea prezentarii : ORA-..."}`). rar_message = body.get("message") if isinstance(body, dict) else None raise RarError( f"postPrezentare esuat (HTTP {resp.status_code})", status_code=resp.status_code, rar_message=rar_message, ) 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}