Files
ERP/pm/backend/app/integrations/erp_client.py
T
MMD 2747f9d4bb chore(pm): vendor the Aramesh/PM platform under pm/ (squash of Aramesh/PM @ 91f3271)
The PM panel (PmPanelPage -> iframe /pm/) needs the project-management
platform served at /pm/ on the ERP domain. Source: org repo Aramesh/PM,
branch feature/project-management-platform. Update via:
git subtree pull --prefix=pm aramesh-pm feature/project-management-platform --squash
2026-10-07 15:14:20 +03:30

161 lines
6.7 KiB
Python

"""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()