feat(agent): integrate procurement AI agent with Bale bot, real-time draft sync, and git history bundle
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
"""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) -> list[dict]:
|
||||
params: dict = {"page_size": page_size}
|
||||
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) -> list[dict]:
|
||||
params: dict = {"page_size": page_size}
|
||||
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 list_categories(self, page_size: int = 100) -> list[dict]:
|
||||
return (await self._request("GET", "/material-categories", params={"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 {}
|
||||
Reference in New Issue
Block a user