- prepayment as DISCOUNT: live API rejects PRE_PAYMENT with 400 third_party.generic - normalize lowercase ETTN statuses returned by the live API - show Checkbox error code instead of bare 'Internal Server Error' - page size 50 for ETTN list, 429 rate limit is retryable, pause polling on it - phone in 380XXXXXXXXX format like portal receipts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
6.8 KiB
Python
172 lines
6.8 KiB
Python
"""Реальный клиент 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)
|
|
# Сколько страниц списка ЕТТН просматривать при сверке после таймаута.
|
|
# Список отдаётся от новых к старым; больше 50 за страницу Checkbox не принимает.
|
|
_FIND_PAGES = 3
|
|
_FIND_PAGE_SIZE = 50
|
|
|
|
|
|
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")
|
|
# Ошибки со стороны Новой Почты Checkbox отдаёт как
|
|
# {"code": "third_party.*", "message": "Internal Server Error"} —
|
|
# без кода кассир видит только бесполезное «Internal Server Error».
|
|
if code := data.get("code"):
|
|
message = f"{message or 'Ошибка'} ({code})"
|
|
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
|
|
# 429 — «Занадто часто виконуються запити»: исход не ошибка данных, повторяемо.
|
|
if response.status_code >= 500 or response.status_code == 429:
|
|
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}")
|