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
161 lines
6.7 KiB
Python
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()
|