"""Async client for the procurement API (/api/v1). Attribution: every call carries ``X-Actor-Id`` (the linked ERP user id) so ``quote_events.actor`` records the human who asked, not the bot. A service bearer token is sent when configured (procurement endpoints are currently open — the slot is for the day auth lands). """ import logging from typing import Any import httpx logger = logging.getLogger("bot.api") class ApiError(Exception): def __init__(self, status: int, code: str, message: str, field: str | None = None): super().__init__(message) self.status = status self.code = code self.message = message self.field = field class ErpClient: def __init__(self, base_url: str, timeout: float = 30.0, service_token: str = ""): self._base = base_url.rstrip("/") self._timeout = timeout self._service_token = service_token def _actor_headers(self, actor_id: str | None, actor_name: str | None) -> dict: headers: dict[str, str] = {} if self._service_token: headers["Authorization"] = f"Bearer {self._service_token}" if actor_id: headers["X-Actor-Id"] = actor_id return headers async def _request(self, method: str, path: str, *, actor_id: str | None = None, json: Any = None, params: dict | None = None) -> Any: url = f"{self._base}{path}" async with httpx.AsyncClient(timeout=self._timeout) as client: try: resp = await client.request( method, url, json=json, params=params, headers=self._actor_headers(actor_id, None) ) except httpx.HTTPError as exc: raise ApiError(0, "NETWORK", f"ERP API unreachable: {exc}") from exc if resp.status_code in (200, 201): return resp.json() try: err = resp.json().get("error") or {} except ValueError: err = {} raise ApiError( resp.status_code, str(err.get("code") or "INTERNAL_ERROR"), str(err.get("message") or f"HTTP {resp.status_code}"), err.get("field"), ) # ------------------------------------------------------------------ auth async def login(self, username: str, password: str) -> dict: body = await self._request("POST", "/auth/login", json={"username": username, "password": password}) return body.get("data") or {} # ---------------------------------------------------------------- lookup async def list_suppliers(self, q: str | None = None, page_size: int = 20, page: int = 1) -> list[dict]: params: dict = {"page_size": page_size, "page": page} if q: params["q"] = q return (await self._request("GET", "/suppliers", params=params)).get("data") or [] async def create_supplier(self, payload: dict, actor_id: str | None = None) -> dict: return (await self._request("POST", "/suppliers", json=payload, actor_id=actor_id)).get("data") or {} async def list_materials(self, q: str | None = None, page_size: int = 20, page: int = 1) -> list[dict]: params: dict = {"page_size": page_size, "page": page} if q: params["q"] = q return (await self._request("GET", "/materials", params=params)).get("data") or [] async def create_material(self, payload: dict, actor_id: str | None = None) -> dict: return (await self._request("POST", "/materials", json=payload, actor_id=actor_id)).get("data") or [] async def list_units(self, q: str | None = None, page_size: int = 100) -> list[dict]: params: dict = {"page_size": page_size} if q: params["q"] = q return (await self._request("GET", "/units", params=params)).get("data") or [] async def list_projects(self, page_size: int = 100) -> list[dict]: return (await self._request("GET", "/projects", params={"page_size": page_size})).get("data") or [] async def create_project(self, payload: dict, actor_id: str | None = None) -> dict: return (await self._request("POST", "/projects", json=payload, actor_id=actor_id)).get("data") or {} async def list_categories(self, page_size: int = 100) -> list[dict]: return (await self._request("GET", "/material-categories", params={"page_size": page_size})).get("data") or [] async def list_brands(self, page: int = 1, page_size: int = 100) -> list[dict]: return (await self._request("GET", "/brands", params={"page": page, "page_size": page_size})).get("data") or [] # ---------------------------------------------------------------- quotes async def list_quotes(self, *, status: str | None = None, q: str | None = None, expand: str | None = None, sort: str | None = None, page: int = 1, page_size: int = 10) -> tuple[list[dict], dict]: params: dict = {"page": page, "page_size": page_size} if status: params["status"] = status if q: params["q"] = q if expand: params["expand"] = expand if sort: params["sort"] = sort body = await self._request("GET", "/quotes", params=params) return body.get("data") or [], body.get("meta") or {} async def get_quote(self, quote_id: str, expand: str | None = None, actor_id: str | None = None) -> dict: params = {"expand": expand} if expand else None return (await self._request("GET", f"/quotes/{quote_id}", params=params, actor_id=actor_id)).get("data") or {} async def create_quote(self, payload: dict, actor_id: str | None = None) -> dict: return (await self._request("POST", "/quotes", json=payload, actor_id=actor_id)).get("data") or {} async def accept_quote(self, quote_id: str, payload: dict, actor_id: str | None = None) -> dict: path = f"/quotes/{quote_id}/accept" return (await self._request("PATCH", path, json=payload, actor_id=actor_id)).get("data") or {} async def arrive_quote(self, quote_id: str, actor_id: str | None = None) -> dict: return (await self._request("PATCH", f"/quotes/{quote_id}/arrive", actor_id=actor_id)).get("data") or {} async def arrive_items(self, quote_id: str, payload: dict, actor_id: str | None = None) -> dict: return (await self._request("PATCH", f"/quotes/{quote_id}/arrive-items", json=payload, actor_id=actor_id)).get("data") or {} async def reject_quote(self, quote_id: str, reason: str | None = None, actor_id: str | None = None) -> dict: return (await self._request( "PATCH", f"/quotes/{quote_id}/reject", json={"reason": reason} if reason else None, actor_id=actor_id )).get("data") or {} async def upload_quote_file(self, quote_id: str, kind: str, file_name: str, content: bytes, mime_type: str = "application/pdf", actor_id: str | None = None) -> dict: url = f"{self._base}/quotes/{quote_id}/{kind}" files = {"file": (file_name, content, mime_type)} async with httpx.AsyncClient(timeout=self._timeout) as client: try: resp = await client.post(url, files=files, headers=self._actor_headers(actor_id, None)) if resp.status_code in (200, 201): return resp.json() except Exception as e: logger.warning("Failed to upload quote attachment to ERP: %s", e) return {}