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
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Integration connectors (ERP/Inventory/Procurement). Phase-2 lives here."""
|
||||
@@ -0,0 +1,160 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user