- All frontend pages, labels, notices and errors; html lang=uk, uk-UA money format - Brand "Assistant System" in the top bar and page title - Backend error details returned to the UI (auth, orders, receipts, cash registers, Checkbox/CRM/NP errors) and CLI output - Tests updated for the new messages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
79 lines
3.5 KiB
Python
79 lines
3.5 KiB
Python
"""Реальный клиент exoCRM (`GetOrders`, `SetStatus`)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from app.core.config import Settings
|
|
from app.schemas.orders import OrderOut
|
|
from app.services.crm.checksum import compute_md5sum
|
|
from app.services.crm.client import CrmError
|
|
|
|
|
|
def _errors_text(data: dict[str, Any]) -> str:
|
|
# Ключ бывает и `errors` (dict код → текст), и `Errors` (список строк).
|
|
errors = data.get("errors") or data.get("Errors") or {}
|
|
if isinstance(errors, dict):
|
|
return "; ".join(f"{code}: {text}" for code, text in errors.items())
|
|
return "; ".join(str(error) for error in errors)
|
|
|
|
|
|
class ExoCrmClient:
|
|
def __init__(self, settings: Settings) -> None:
|
|
self._base_url = settings.crm_base_url
|
|
self._api_key = settings.crm_api_key
|
|
self._secret_key = settings.crm_secret_key
|
|
self._shop_key = settings.crm_shop_key
|
|
self._sid = settings.crm_sid
|
|
|
|
async def _post(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
body = {"apikey": self._api_key, "object": "Orders", "method": method, "params": params}
|
|
body["md5sum"] = compute_md5sum(body, self._secret_key)
|
|
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
response = await client.post(self._base_url, json=body)
|
|
response.raise_for_status()
|
|
try:
|
|
data = response.json()
|
|
except ValueError as exc:
|
|
# PHP-notice'ы CRM перед JSON — признак неверных параметров.
|
|
raise CrmError(f"CRM повернула не JSON: {response.text[:300]}") from exc
|
|
|
|
if not isinstance(data, dict):
|
|
raise CrmError(f"Неочікувана відповідь CRM: {str(data)[:300]}")
|
|
return data
|
|
|
|
async def get_orders(self, *, status: str) -> list[OrderOut]:
|
|
data = await self._post(
|
|
"GetOrders",
|
|
{
|
|
"sid": self._sid,
|
|
"key": self._shop_key,
|
|
"Status": status,
|
|
"ReturnGoods": True,
|
|
"ReturnTotals": True,
|
|
},
|
|
)
|
|
if data.get("status") != "OK":
|
|
raise CrmError(f"CRM повернула помилку: {_errors_text(data) or 'невідома помилка'}")
|
|
return [OrderOut.model_validate(order) for order in data.get("result") or []]
|
|
|
|
async def set_status(self, *, order_id: str, status: str) -> None:
|
|
"""`SetStatus`. Формат проверен на боевой CRM и расходится с документацией.
|
|
|
|
Документация показывает `params: {"ID": ..., "Status": ...}`, но CRM на это
|
|
отвечает «Undefined order list.». Рабочий вариант — список ID в `Orders`,
|
|
а ответ — результат по каждому заказу без общего `status: OK`:
|
|
{"123901": {"Status": "Success", "ChangeStatus": "Success"}}
|
|
"""
|
|
data = await self._post("SetStatus", {"Orders": [order_id], "Status": status})
|
|
result = data.get(order_id)
|
|
if isinstance(result, dict) and result.get("Status") == "Success":
|
|
return
|
|
details = _errors_text(data) or (
|
|
str(result) if result is not None else "немає відповіді щодо замовлення"
|
|
)
|
|
raise CrmError(f"CRM не змінила статус замовлення {order_id} на {status}: {details}")
|