Add Checkbox ETTN receipts for Nova Poshta COD waybills
Cashier creates an ETTN receipt in Checkbox bound to the TTN with payment control; Checkbox fiscalizes it itself when the parcel is paid for. - cash_registers (Fernet-encrypted license key / PIN) and receipts tables - Checkbox HTTP client + stub (ETTN does not work on test registers) - two-phase create via ARQ job, timeout reconciliation, cron status polling - /receipts and /cash-registers API, audit records - dashboard: per-order and bulk create, prepayment, cancel; cash registers page Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
"""Protocol клиента Checkbox — позволяет подменять реализацию в тестах и локально.
|
||||
|
||||
См. `StubCheckboxClient`. Выбор реализации — `get_checkbox_client()` ниже,
|
||||
единственное место, читающее `CHECKBOX_USE_STUB`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Any, Protocol
|
||||
|
||||
from app.core.config import settings
|
||||
from app.schemas.checkbox import EttnOut
|
||||
|
||||
|
||||
class CheckboxError(Exception):
|
||||
"""Checkbox отклонил запрос (4xx) — повтор с тем же телом не поможет."""
|
||||
|
||||
|
||||
class CheckboxUnavailableError(CheckboxError):
|
||||
"""Сеть, таймаут или 5xx — исход запроса неизвестен, можно повторить."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CheckboxCredentials:
|
||||
"""Расшифрованные доступы одной кассы. Живут только в памяти."""
|
||||
|
||||
cash_register_id: uuid.UUID
|
||||
license_key: str
|
||||
pin_code: str
|
||||
|
||||
|
||||
class CheckboxClient(Protocol):
|
||||
async def sign_in(self, creds: CheckboxCredentials) -> None: ...
|
||||
|
||||
async def create_ettn(self, creds: CheckboxCredentials, body: dict[str, Any]) -> EttnOut: ...
|
||||
|
||||
async def get_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> EttnOut: ...
|
||||
|
||||
async def find_ettn(
|
||||
self, creds: CheckboxCredentials, waybill_number: str
|
||||
) -> EttnOut | None: ...
|
||||
|
||||
async def delete_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> None: ...
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_checkbox_client() -> CheckboxClient:
|
||||
"""Один экземпляр на процесс: внутри — кэш токенов кассиров."""
|
||||
if settings.checkbox_use_stub:
|
||||
if settings.is_production:
|
||||
raise RuntimeError("CHECKBOX_USE_STUB=true запрещён в production")
|
||||
from app.services.checkbox.stub_client import StubCheckboxClient
|
||||
|
||||
return StubCheckboxClient(auto_complete_after=2)
|
||||
|
||||
from app.services.checkbox.http_client import HttpCheckboxClient
|
||||
|
||||
return HttpCheckboxClient(settings)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Реальный клиент 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}")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Стаб Checkbox для тестов и локальной разработки.
|
||||
|
||||
ЕТТН-чеки на тестовой кассе Checkbox не работают, поэтому полный цикл без
|
||||
боевой кассы прогоняется только через стаб. Состояние — в памяти процесса.
|
||||
|
||||
`auto_complete_after=N`: чек переходит в `DONE` на N-м вызове `get_ettn` —
|
||||
имитирует получение посылки клиентом.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.schemas.checkbox import EttnOut, EttnStatus
|
||||
from app.services.checkbox.client import CheckboxCredentials, CheckboxError
|
||||
|
||||
|
||||
class StubCheckboxClient:
|
||||
def __init__(self, *, auto_complete_after: int | None = None) -> None:
|
||||
self.auto_complete_after = auto_complete_after
|
||||
self.orders: dict[str, EttnOut] = {}
|
||||
self.bodies: dict[str, dict[str, Any]] = {}
|
||||
self._polls: dict[str, int] = {}
|
||||
|
||||
def set_status(self, ettn_id: str, status: str, *, raw_error: str | None = None) -> None:
|
||||
ettn = self.orders[ettn_id]
|
||||
receipt_id = str(uuid.uuid4()) if status.startswith(EttnStatus.DONE) else ettn.receipt_id
|
||||
self.orders[ettn_id] = ettn.model_copy(
|
||||
update={"status": status, "receipt_id": receipt_id, "raw_error": raw_error}
|
||||
)
|
||||
|
||||
async def sign_in(self, creds: CheckboxCredentials) -> None:
|
||||
if not creds.license_key or not creds.pin_code:
|
||||
raise CheckboxError("Вход кассира не удался: пустой ключ или PIN")
|
||||
|
||||
async def create_ettn(self, creds: CheckboxCredentials, body: dict[str, Any]) -> EttnOut:
|
||||
waybill = body["receipt_body"]["payments"][0]["ettn"]
|
||||
if await self.find_ettn(creds, waybill) is not None:
|
||||
raise CheckboxError(f"ЕТТН {waybill} уже привязана к чеку")
|
||||
ettn = EttnOut(
|
||||
id=str(uuid.uuid4()),
|
||||
status=EttnStatus.CREATED,
|
||||
ettn_number=waybill,
|
||||
total_sum=body["receipt_body"]["payments"][0]["value"],
|
||||
)
|
||||
self.orders[ettn.id] = ettn
|
||||
self.bodies[ettn.id] = body
|
||||
return ettn
|
||||
|
||||
async def get_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> EttnOut:
|
||||
if ettn_id not in self.orders:
|
||||
raise CheckboxError(f"ЕТТН-чек {ettn_id} не найден")
|
||||
self._polls[ettn_id] = self._polls.get(ettn_id, 0) + 1
|
||||
if (
|
||||
self.auto_complete_after is not None
|
||||
and self.orders[ettn_id].status == EttnStatus.CREATED
|
||||
and self._polls[ettn_id] >= self.auto_complete_after
|
||||
):
|
||||
self.set_status(ettn_id, EttnStatus.DONE)
|
||||
return self.orders[ettn_id]
|
||||
|
||||
async def find_ettn(self, creds: CheckboxCredentials, waybill_number: str) -> EttnOut | None:
|
||||
for ettn in self.orders.values():
|
||||
if ettn.ettn_number == waybill_number and ettn.status != EttnStatus.CANCELLED:
|
||||
return ettn
|
||||
return None
|
||||
|
||||
async def delete_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> None:
|
||||
# API и worker — разные процессы с разными стабами: неизвестный id
|
||||
# считаем уже удалённым, иначе локально отмена не работала бы.
|
||||
if ettn_id in self.orders:
|
||||
self.set_status(ettn_id, EttnStatus.CANCELLED)
|
||||
Reference in New Issue
Block a user