"""Реальный клиент Checkbox (ЕТТН-чеки, `/api/v1/ettn`). Авторизация — токен кассира по PIN-коду (`/api/v1/cashier/signinPinCode`). Токен кэшируется в памяти процесса по кассе; на 401 — один повторный вход. """ from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta from typing import Any import httpx from app.core.config import Settings from app.schemas.checkbox import EttnOut, EttnStatus from app.services.checkbox.client import ( CheckboxCredentials, CheckboxError, CheckboxUnavailableError, ) _PROVIDER = "novapost" _TIMEOUT = httpx.Timeout(30, connect=10) # Сколько страниц списка ЕТТН просматривать при сверке после таймаута. _FIND_PAGES = 5 _FIND_PAGE_SIZE = 100 def _error_message(response: httpx.Response) -> str: try: data = response.json() except ValueError: return f"HTTP {response.status_code}: {response.text[:500]}" if isinstance(data, dict): message = data.get("message") detail = data.get("detail") if isinstance(detail, list) and detail: parts = [ f"{'.'.join(str(p) for p in item.get('loc', []))}: {item.get('msg')}" for item in detail if isinstance(item, dict) ] return "; ".join(filter(None, [message, *parts])) if message: return str(message) if detail: return str(detail) return f"HTTP {response.status_code}" class HttpCheckboxClient: def __init__(self, settings: Settings) -> None: self._base_url = settings.checkbox_base_url.rstrip("/") self._client_headers = { "X-Client-Name": settings.checkbox_client_name, "X-Client-Version": settings.checkbox_client_version, } self._tokens: dict[uuid.UUID, str] = {} async def _send( self, method: str, path: str, *, headers: dict[str, str], json: Any = None, params: dict[str, Any] | None = None, ) -> httpx.Response: try: async with httpx.AsyncClient(base_url=self._base_url, timeout=_TIMEOUT) as client: response = await client.request( method, path, headers=headers, json=json, params=params ) except httpx.HTTPError as exc: raise CheckboxUnavailableError(f"Checkbox недоступен: {exc!r}") from exc if response.status_code >= 500: raise CheckboxUnavailableError(_error_message(response)) return response async def _sign_in(self, creds: CheckboxCredentials) -> str: response = await self._send( "POST", "/api/v1/cashier/signinPinCode", headers={**self._client_headers, "X-License-Key": creds.license_key}, json={"pin_code": creds.pin_code}, ) if response.status_code != 200: raise CheckboxError(f"Вход кассира не удался: {_error_message(response)}") token = response.json()["access_token"] self._tokens[creds.cash_register_id] = token return token async def _request( self, creds: CheckboxCredentials, method: str, path: str, *, json: Any = None, params: dict[str, Any] | None = None, ) -> httpx.Response: token = self._tokens.get(creds.cash_register_id) or await self._sign_in(creds) for attempt in range(2): response = await self._send( method, path, headers={ **self._client_headers, "X-License-Key": creds.license_key, "Authorization": f"Bearer {token}", }, json=json, params=params, ) if response.status_code == 401 and attempt == 0: token = await self._sign_in(creds) continue break if response.status_code >= 400: raise CheckboxError(_error_message(response)) return response async def sign_in(self, creds: CheckboxCredentials) -> None: await self._sign_in(creds) async def create_ettn(self, creds: CheckboxCredentials, body: dict[str, Any]) -> EttnOut: response = await self._request(creds, "POST", "/api/v1/ettn", json=body) return EttnOut.model_validate(response.json()) async def get_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> EttnOut: response = await self._request(creds, "GET", f"/api/v1/ettn/{ettn_id}") return EttnOut.model_validate(response.json()) async def find_ettn(self, creds: CheckboxCredentials, waybill_number: str) -> EttnOut | None: """Ищет неотменённый ЕТТН-чек по номеру ТТН за последнюю неделю. Нужен после таймаута создания: POST мог дойти до Checkbox, и слепой повтор создал бы второй чек на ту же ТТН. """ date_from = (datetime.now(UTC) - timedelta(days=7)).isoformat() for page in range(_FIND_PAGES): response = await self._request( creds, "GET", "/api/v1/ettn", params={ "provider": _PROVIDER, "date_from": date_from, "limit": _FIND_PAGE_SIZE, "offset": page * _FIND_PAGE_SIZE, }, ) items = response.json() for item in items: ettn = EttnOut.model_validate(item) if ettn.ettn_number == waybill_number and ettn.status != EttnStatus.CANCELLED: return ettn if len(items) < _FIND_PAGE_SIZE: break return None async def delete_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> None: await self._request(creds, "DELETE", f"/api/v1/ettn/{ettn_id}")