"""ERP (procurement/inventory) read-only connector. Auth: Casdoor password grant with a dedicated service account (no changes to the ERP codebase). The token is cached until expiry. ERP stays the source of truth for physical stock; PM only READS it (reservations/purchase requests are PM-owned operational overlays). """ import asyncio import logging import time from typing import Any import httpx from app.config import settings from app.errors import UpstreamError logger = logging.getLogger("pm.erp") class ERPClient: def __init__(self): self._token: str | None = None self._token_exp: float = 0 self._lock = asyncio.Lock() async def _ensure_token(self) -> str: async with self._lock: if self._token and time.time() < self._token_exp - 30: return self._token usernames = [ settings.ERP_SERVICE_USERNAME, f"{settings.ERP_CASDOOR_ORGANIZATION}/{settings.ERP_SERVICE_USERNAME}", ] last_error: Exception | None = None async with httpx.AsyncClient(timeout=12) as client: for username in usernames: try: resp = await client.post( f"{settings.ERP_CASDOOR_ENDPOINT}/api/login/oauth/access_token", data={ "grant_type": "password", "client_id": settings.ERP_CASDOOR_CLIENT_ID, "client_secret": settings.ERP_CASDOOR_CLIENT_SECRET, "username": username, "password": settings.ERP_SERVICE_PASSWORD, }, ) resp.raise_for_status() data = resp.json() token = data.get("access_token") if token: self._token = token self._token_exp = time.time() + int(data.get("expires_in") or 3600) return token except Exception as exc: # noqa: BLE001 last_error = exc logger.warning("ERP token grant failed for %s: %s", username, exc) raise UpstreamError(f"احراز هویت سرویس با ERP/Casdoor ناموفق بود: {last_error}") async def get(self, path: str, params: dict | None = None) -> Any: token = await self._ensure_token() try: async with httpx.AsyncClient(timeout=15) as client: resp = await client.get( f"{settings.ERP_BASE_URL}{path}", params=params, headers={"Authorization": f"Bearer {token}"}, ) except httpx.HTTPError as exc: raise UpstreamError(f"ERP در دسترس نیست: {exc.__class__.__name__}") from exc if resp.status_code == 401: self._token = None raise UpstreamError("توکن سرویس ERP نامعتبر شد") if resp.status_code >= 400: logger.warning("ERP GET %s → %s %s", path, resp.status_code, resp.text[:200]) raise UpstreamError(f"خطای ERP ({resp.status_code})") if not resp.content: return {} return resp.json() # --- domain reads (normalize to PM-friendly dicts) ----------------------- async def materials(self) -> list[dict]: """Global material catalog: [{id, name, unit_id, unit_name}]""" materials: list[dict] = [] page = 1 while True: data = await self.get(f"/api/v1/materials?page_size=100&page={page}") elements = data.get("data", []) if isinstance(data, dict) else [] materials.extend(elements) meta = data.get("meta", {}) if isinstance(data, dict) else {} if not elements or page * 100 >= int(meta.get("total") or len(materials)): break page += 1 unit_names: dict[int, str] = {} try: units = await self.get("/api/v1/units?page_size=100") for u in units.get("data", []): unit_names[u.get("id")] = u.get("name_fa") or u.get("name") or u.get("code") or "" except Exception: # noqa: BLE001 — unit names are cosmetic pass return [ { "id": m.get("id"), "name": m.get("name") or "", "unit_id": m.get("canonical_unit_id"), "unit_name": unit_names.get(m.get("canonical_unit_id"), ""), } for m in materials if m.get("id") and (m.get("status") or "active") == "active" ] async def projects(self) -> list[dict]: """ERP projects for the mapping picker: [{id, name}]""" data = await self.get("/api/v1/projects?page_size=100") elements = data.get("data", []) if isinstance(data, dict) else [] return [{"id": p.get("id"), "name": p.get("name") or ""} for p in elements if p.get("id")] async def warehouses(self, erp_project_id: str) -> list[dict]: data = await self.get("/api/v1/inventory/warehouses") elements = data.get("data", data) if isinstance(data, dict) else data if isinstance(elements, dict): elements = elements.get("data", []) return [w for w in (elements or []) if w.get("project_id") == erp_project_id] async def inventory_rows(self) -> list[dict]: """Normalized stock rows: [{warehouse_id, material_id, quantity}]. ERP returns nested objects (warehouse: {...}, material: {...}) plus a numeric `quantity_number` — flatten to the three fields PM needs. """ rows: list[dict] = [] page = 1 while True: data = await self.get(f"/api/v1/inventory?page_size=100&page={page}") elements = data.get("data", []) if isinstance(data, dict) else [] for r in elements: warehouse = r.get("warehouse") or {} material = r.get("material") or {} qty = r.get("quantity_number") if qty is None: qty = r.get("quantity") rows.append( { "warehouse_id": warehouse.get("id"), "material_id": material.get("id"), "quantity": float(qty or 0), } ) meta = data.get("meta", {}) if isinstance(data, dict) else {} if not elements or page * 100 >= int(meta.get("total") or len(rows)): break page += 1 return rows erp_client = ERPClient()