Release v4928.1.4.2 stable
This commit is contained in:
234
app/jasmin_client.py
Normal file
234
app/jasmin_client.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""Cliente REST mínimo para Cegid Jasmin.
|
||||
|
||||
Validado contra Jasmin 3.02:
|
||||
- OAuth Client Credentials em identity.primaverabss.com/connect/token;
|
||||
- endpoints em https://my.jasminsoftware.com/api/{account}/{subscription};
|
||||
- OData com $top máximo 100;
|
||||
- invoice from quotation exige body JSON vazio ({}) para evitar HTTP 411.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class JasminError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JasminConfig:
|
||||
base_url: str
|
||||
token_url: str
|
||||
account: str
|
||||
subscription: str
|
||||
client_id: str
|
||||
client_secret: str
|
||||
scope: str = "application"
|
||||
|
||||
|
||||
_TOKEN_CACHE: Dict[str, Any] = {"access_token": "", "expires_at": 0.0}
|
||||
|
||||
|
||||
def get_jasmin_config() -> JasminConfig:
|
||||
cfg = JasminConfig(
|
||||
base_url=(settings.jasmin_base_url or "https://my.jasminsoftware.com").rstrip("/"),
|
||||
token_url=(settings.jasmin_token_url or "https://identity.primaverabss.com/connect/token").strip(),
|
||||
account=(settings.jasmin_account or "").strip(),
|
||||
subscription=(settings.jasmin_subscription or "").strip(),
|
||||
client_id=(settings.jasmin_client_id or "").strip(),
|
||||
client_secret=(settings.jasmin_client_secret or "").strip(),
|
||||
scope=(settings.jasmin_scope or "application").strip() or "application",
|
||||
)
|
||||
missing = [name for name, value in {
|
||||
"JASMIN_ACCOUNT": cfg.account,
|
||||
"JASMIN_SUBSCRIPTION": cfg.subscription,
|
||||
"JASMIN_CLIENT_ID": cfg.client_id,
|
||||
"JASMIN_CLIENT_SECRET": cfg.client_secret,
|
||||
}.items() if not value]
|
||||
if missing:
|
||||
raise JasminError("Configuração Jasmin incompleta: " + ", ".join(missing))
|
||||
return cfg
|
||||
|
||||
|
||||
class JasminClient:
|
||||
def __init__(self, config: Optional[JasminConfig] = None, timeout: float = 30.0):
|
||||
self.config = config or get_jasmin_config()
|
||||
self.timeout = timeout
|
||||
|
||||
@property
|
||||
def api_root(self) -> str:
|
||||
return f"{self.config.base_url}/api/{self.config.account}/{self.config.subscription}"
|
||||
|
||||
async def get_token(self, *, force_refresh: bool = False) -> str:
|
||||
now = time.time()
|
||||
if not force_refresh and _TOKEN_CACHE.get("access_token") and float(_TOKEN_CACHE.get("expires_at") or 0) > now + 60:
|
||||
return str(_TOKEN_CACHE["access_token"])
|
||||
|
||||
data = {"grant_type": "client_credentials", "scope": self.config.scope}
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
self.config.token_url,
|
||||
data=data,
|
||||
auth=(self.config.client_id, self.config.client_secret),
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"},
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise JasminError(f"OAuth Jasmin falhou {response.status_code}: {response.text}")
|
||||
payload = response.json()
|
||||
token = payload.get("access_token")
|
||||
if not token:
|
||||
raise JasminError(f"OAuth Jasmin não devolveu access_token: {payload}")
|
||||
expires_in = int(payload.get("expires_in") or 3600)
|
||||
_TOKEN_CACHE.update({"access_token": token, "expires_at": now + expires_in})
|
||||
return str(token)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json_body: Any = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> Any:
|
||||
token = await self.get_token()
|
||||
url = f"{self.api_root}/{path.lstrip('/')}"
|
||||
request_headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if json_body is not None:
|
||||
request_headers["Content-Type"] = "application/json"
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
response = await client.request(
|
||||
method.upper(),
|
||||
url,
|
||||
params=params,
|
||||
json=json_body,
|
||||
headers=request_headers,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
detail: Any = response.text
|
||||
try:
|
||||
detail = response.json()
|
||||
except Exception:
|
||||
pass
|
||||
raise JasminError(f"Jasmin error {response.status_code} em {method.upper()} {path}: {detail}")
|
||||
if not response.content:
|
||||
return None
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
return response.text
|
||||
|
||||
|
||||
async def _raw_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
accept: str = "application/pdf",
|
||||
json_body: Any = None,
|
||||
) -> tuple[bytes, str]:
|
||||
"""Pedido autenticado para respostas binárias, usado por PDFs Jasmin."""
|
||||
token = await self.get_token()
|
||||
url = f"{self.api_root}/{path.lstrip('/')}"
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": accept}
|
||||
if json_body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
response = await client.request(method.upper(), url, json=json_body, headers=headers)
|
||||
if response.status_code >= 400:
|
||||
detail: Any = response.text
|
||||
try:
|
||||
detail = response.json()
|
||||
except Exception:
|
||||
pass
|
||||
raise JasminError(f"Jasmin error {response.status_code} em {method.upper()} {path}: {detail}")
|
||||
return response.content, response.headers.get("content-type", accept)
|
||||
|
||||
async def print_quotation_pdf(self, quotation_id: str) -> tuple[bytes, str]:
|
||||
return await self._raw_request("GET", f"/sales/quotations/{quotation_id}/print", accept="application/pdf")
|
||||
|
||||
async def print_invoice_pdf(self, invoice_id: str) -> tuple[bytes, str]:
|
||||
return await self._raw_request("GET", f"/billing/invoices/{invoice_id}/print", accept="application/pdf")
|
||||
|
||||
async def get_versions(self) -> Dict[str, Any]:
|
||||
return await self._request("GET", "/businessCore/productInfos/getVersions")
|
||||
|
||||
async def list_customers_odata(self, *, top: int = 100, skip: int = 0) -> Dict[str, Any]:
|
||||
top = min(max(int(top), 1), 100)
|
||||
return await self._request("GET", "/salesCore/customerParties/extension/odata", params={"$top": top, "$skip": int(skip)})
|
||||
|
||||
async def get_customer_by_tax_id(self, tax_id: str) -> Any:
|
||||
return await self._request("GET", f"/salesCore/customerParties/getCustomerByCompanyTaxId/{tax_id}")
|
||||
|
||||
async def get_customer_by_party_key(self, party_key: str) -> Dict[str, Any]:
|
||||
return await self._request("GET", f"/salesCore/customerParties/{party_key}")
|
||||
|
||||
async def create_customer(self, payload: Dict[str, Any]) -> str:
|
||||
result = await self._request("POST", "/salesCore/customerParties", json_body=payload)
|
||||
return str(result).strip('"')
|
||||
|
||||
async def list_sales_items(self, *, top: int = 100, skip: int = 0) -> Dict[str, Any]:
|
||||
top = min(max(int(top), 1), 100)
|
||||
return await self._request("GET", "/salesCore/salesItems/extension/odata", params={"$top": top, "$skip": int(skip)})
|
||||
|
||||
async def get_sales_item(self, item_key: str) -> Dict[str, Any]:
|
||||
return await self._request("GET", f"/salesCore/salesItems/{item_key}")
|
||||
|
||||
async def create_quotation(self, payload: Dict[str, Any]) -> str:
|
||||
result = await self._request("POST", "/sales/quotations", json_body=payload)
|
||||
return str(result).strip('"')
|
||||
|
||||
async def get_quotation(self, quotation_id: str) -> Dict[str, Any]:
|
||||
return await self._request("GET", f"/sales/quotations/{quotation_id}")
|
||||
|
||||
async def list_quotations(
|
||||
self,
|
||||
*,
|
||||
top: int = 100,
|
||||
skip: int = 0,
|
||||
filter: Optional[str] = None,
|
||||
orderby: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
top = min(max(int(top), 1), 100)
|
||||
params: Dict[str, Any] = {"$top": top, "$skip": int(skip)}
|
||||
if filter:
|
||||
params["$filter"] = filter
|
||||
if orderby:
|
||||
params["$orderby"] = orderby
|
||||
return await self._request("GET", "/sales/quotations/odata", params=params)
|
||||
|
||||
async def create_invoice_from_quotation(self, quotation_id: str) -> str:
|
||||
# Body vazio é obrigatório no tenant testado; sem body devolve HTTP 411.
|
||||
result = await self._request("POST", f"/billing/invoices/fromQuotation/{quotation_id}", json_body={})
|
||||
return str(result).strip('"')
|
||||
|
||||
async def get_invoice(self, invoice_id: str) -> Dict[str, Any]:
|
||||
return await self._request("GET", f"/billing/invoices/{invoice_id}")
|
||||
|
||||
async def list_invoices(
|
||||
self,
|
||||
*,
|
||||
top: int = 100,
|
||||
skip: int = 0,
|
||||
filter: Optional[str] = None,
|
||||
orderby: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
top = min(max(int(top), 1), 100)
|
||||
params: Dict[str, Any] = {"$top": top, "$skip": int(skip)}
|
||||
if filter:
|
||||
params["$filter"] = filter
|
||||
if orderby:
|
||||
params["$orderby"] = orderby
|
||||
return await self._request("GET", "/billing/invoices/odata", params=params)
|
||||
Reference in New Issue
Block a user