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:
2026-09-23 23:39:00 +03:00
co-authored by Claude Opus 5.5
parent d13e7ce2b3
commit 518a99197f
44 changed files with 2931 additions and 20 deletions
+3
View File
@@ -22,6 +22,9 @@ _REDACTED_KEYS = {
"secret",
"license_key",
"api_key",
"pin",
"pin_code",
"cashier_pin",
}
+61
View File
@@ -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)
+490
View File
@@ -0,0 +1,490 @@
"""ЕТТН-чеки Checkbox: создание по ТТН с контролем оплаты, отмена, опрос статусов.
Чек не фискализируется нами — Checkbox делает это сам, когда клиент оплачивает
посылку в отделении НП. Поэтому ЕТТН-чек должен быть создан до получения посылки,
а сумма к оплате (наложка НП) обязана совпасть с суммой чека:
сумма товаров − скидки − предоплата == np_cod_amount_kopecks
Создание двухфазное: API пишет `Receipt(pending)` (+ аудит) и ставит задачу,
worker вызывает Checkbox (`create_ettn_for_receipt`). Так кассир не ждёт сеть,
а сбой Checkbox не теряет запрос — `retry_pending_receipts` его повторит.
"""
from __future__ import annotations
import re
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
from fastapi import Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import crypto
from app.core.logging import get_logger
from app.db.models.audit import AuditAction
from app.db.models.cash_register import CashRegister
from app.db.models.order import Order
from app.db.models.receipt import CLOSED_STATUSES, Receipt, ReceiptStatus
from app.db.models.user import User
from app.schemas.checkbox import EttnOut, EttnStatus
from app.services import audit
from app.services.checkbox.client import (
CheckboxClient,
CheckboxCredentials,
CheckboxError,
CheckboxUnavailableError,
)
log = get_logger(__name__)
# Коды статусов НП, при которых ЕТТН-чек создавать поздно или бессмысленно:
# посылка уже получена, возвращается/возвращена или ТТН удалена.
NP_FINAL_STATUS_CODES = frozenset(
{"2", "9", "10", "11", "102", "103", "105", "106", "108"}
)
# Статус Checkbox → наш статус. CREATED не меняет ничего.
_CHECKBOX_TO_STATUS = {
EttnStatus.DONE: ReceiptStatus.DONE,
EttnStatus.DONE_WITHOUT_SMS: ReceiptStatus.DONE,
EttnStatus.RETURNED: ReceiptStatus.RETURNED,
EttnStatus.RECEIPT_ERROR: ReceiptStatus.RECEIPT_ERROR,
EttnStatus.CANCELLED: ReceiptStatus.CANCELLED,
}
# Сколько ждать, прежде чем считать `pending`-чек зависшим (задача потеряна
# или Checkbox был недоступен) и повторить его из cron'а.
_PENDING_RETRY_AFTER = timedelta(minutes=1)
class ReceiptValidationError(Exception):
"""Заказ нельзя отправить в Checkbox — сообщение показывается кассиру."""
# --- Деньги и количества -----------------------------------------------------
def to_kopecks(amount: str | None) -> int:
if not amount:
return 0
return int((Decimal(amount) * 100).to_integral_value(ROUND_HALF_UP))
def to_thousandths(quantity: str) -> int:
return int((Decimal(quantity) * 1000).to_integral_value(ROUND_HALF_UP))
def _line_sum(price_kopecks: int, quantity: int) -> int:
return int((Decimal(price_kopecks) * quantity / 1000).to_integral_value(ROUND_HALF_UP))
def _normalize_phone(phone: str | None) -> str | None:
"""Телефон для отправки чека: Checkbox принимает только `+?380\\d{9}`."""
if not phone:
return None
digits = re.sub(r"\D", "", phone)
if len(digits) == 10 and digits.startswith("0"):
digits = "38" + digits
return f"+{digits}" if re.fullmatch(r"380\d{9}", digits) else None
# --- Тело запроса ------------------------------------------------------------
@dataclass(frozen=True)
class ReceiptAmounts:
total_kopecks: int # к оплате по заказу после всех скидок
prepayment_kopecks: int
cod_kopecks: int # наложка НП = total − prepayment
def build_goods(order: Order, tax_codes: list[Any]) -> tuple[list[dict[str, Any]], int]:
"""Товары CRM → `goods` Checkbox. Возвращает позиции и их сумму после скидок."""
items: list[dict[str, Any]] = []
total = 0
for good in order.goods:
price = to_kopecks(good["price"])
quantity = to_thousandths(good["quantity"])
if quantity <= 0:
raise ReceiptValidationError(f"Товар «{good['name']}»: количество должно быть больше 0")
gross = _line_sum(price, quantity)
# `amount` — сумма строки после скидки CRM; скидку выводим из неё,
# а не из discount_amount/percent, чтобы не расходиться с итогом CRM.
net = to_kopecks(good.get("amount")) if good.get("amount") else gross
discount = gross - net
if discount < 0:
raise ReceiptValidationError(
f"Товар «{good['name']}»: сумма строки больше цены × количество"
)
payload: dict[str, Any] = {
"code": good.get("sku") or good["id"],
"name": good["name"],
"price": price,
}
if tax_codes:
payload["tax"] = list(tax_codes)
item: dict[str, Any] = {"good": payload, "quantity": quantity, "is_return": False}
if discount:
item["discounts"] = [{"type": "DISCOUNT", "mode": "VALUE", "value": discount}]
items.append(item)
total += net
return items, total
def build_ettn_body(
order: Order, register: CashRegister, amounts: ReceiptAmounts
) -> dict[str, Any]:
goods, goods_total = build_goods(order, register.tax_codes)
discounts: list[dict[str, Any]] = []
# Скидка на весь заказ в CRM (не распределённая по строкам).
order_discount = goods_total - amounts.total_kopecks
if order_discount < 0:
raise ReceiptValidationError(
"Сумма товаров меньше суммы заказа — проверьте заказ в CRM"
)
if order_discount:
discounts.append(
{"type": "DISCOUNT", "mode": "VALUE", "value": order_discount, "name": "Знижка"}
)
if amounts.prepayment_kopecks:
discounts.append(
{
"type": "PRE_PAYMENT",
"mode": "VALUE",
"value": amounts.prepayment_kopecks,
"name": "Передоплата",
}
)
receipt_body: dict[str, Any] = {
"goods": goods,
"payments": [{"value": amounts.cod_kopecks, "ettn": order.waybill_number}],
}
if discounts:
receipt_body["discounts"] = discounts
delivery: dict[str, Any] = {}
if phone := _normalize_phone(order.recipient_phone):
delivery["phone"] = phone
if order.recipient_email:
delivery["emails"] = [order.recipient_email]
if delivery:
receipt_body["delivery"] = delivery
return {"provider": "novapost", "receipt_body": receipt_body}
def resolve_amounts(order: Order, prepayment_kopecks: int | None) -> ReceiptAmounts:
"""Проверяет, что по заказу можно создать ЕТТН-чек, и считает суммы.
`prepayment_kopecks=None` — взять разницу между суммой заказа и наложкой.
"""
if order.is_deleted:
raise ReceiptValidationError("Заказ удалён")
if not order.waybill_number:
raise ReceiptValidationError("У заказа нет ТТН")
if not order.np_cod_amount_kopecks:
raise ReceiptValidationError("По ТТН нет суммы контроля оплаты (наложки)")
if order.np_status_code in NP_FINAL_STATUS_CODES:
raise ReceiptValidationError(
f"Посылка уже не в пути ({order.np_status or order.np_status_code}) — "
"ЕТТН-чек создать нельзя"
)
if not order.goods:
raise ReceiptValidationError("В заказе нет товаров")
total = order.total_amount_kopecks
cod = order.np_cod_amount_kopecks
prepayment = total - cod if prepayment_kopecks is None else prepayment_kopecks
if prepayment < 0:
raise ReceiptValidationError(
f"Наложка {cod / 100:.2f} ₴ больше суммы заказа {total / 100:.2f} ₴"
)
if total - prepayment != cod:
raise ReceiptValidationError(
f"Сумма заказа {total / 100:.2f} ₴ − предоплата {prepayment / 100:.2f} ₴ "
f"≠ наложка {cod / 100:.2f} ₴"
)
return ReceiptAmounts(total_kopecks=total, prepayment_kopecks=prepayment, cod_kopecks=cod)
# --- Кассы -------------------------------------------------------------------
def credentials(register: CashRegister) -> CheckboxCredentials:
return CheckboxCredentials(
cash_register_id=register.id,
license_key=crypto.decrypt(register.license_key_enc),
pin_code=crypto.decrypt(register.cashier_pin_enc),
)
async def get_default_register(session: AsyncSession) -> CashRegister | None:
return await session.scalar(
select(CashRegister)
.where(CashRegister.is_active.is_(True))
.order_by(CashRegister.is_default.desc(), CashRegister.created_at)
.limit(1)
)
# --- Создание ----------------------------------------------------------------
@dataclass
class RequestResult:
created: list[Receipt]
errors: dict[str, str]
async def _active_receipt_order_ids(session: AsyncSession, order_ids: list[str]) -> set[str]:
result = await session.scalars(
select(Receipt.order_id)
.where(Receipt.order_id.in_(order_ids))
.where(Receipt.status.not_in(CLOSED_STATUSES))
)
return set(result)
async def request_receipts(
session: AsyncSession,
items: list[tuple[str, int | None]],
*,
user: User,
request: Request | None = None,
) -> RequestResult:
"""Создаёт `Receipt(pending)` по каждому подходящему заказу.
Без commit и без обращения к Checkbox: вызывающий код коммитит и ставит
задачи worker'у (`create_ettn_for_receipt`) по `result.created`.
Ошибки по отдельным заказам не мешают остальным — массовое действие.
"""
result = RequestResult(created=[], errors={})
register = await get_default_register(session)
if register is None:
for order_id, _ in items:
result.errors[order_id] = "Не настроена касса Checkbox"
return result
order_ids = [order_id for order_id, _ in items]
orders = {
order.id: order
for order in await session.scalars(select(Order).where(Order.id.in_(order_ids)))
}
busy = await _active_receipt_order_ids(session, order_ids)
for order_id, prepayment in items:
order = orders.get(order_id)
if order is None:
result.errors[order_id] = "Заказ не найден"
continue
if order_id in busy:
result.errors[order_id] = "По заказу уже есть чек"
continue
try:
amounts = resolve_amounts(order, prepayment)
body = build_ettn_body(order, register, amounts)
except ReceiptValidationError as exc:
result.errors[order_id] = str(exc)
continue
now = datetime.now(UTC)
receipt = Receipt(
id=uuid.uuid4(),
# Явно, а не server_default: после commit ответ API читает created_at,
# а незагруженный server_default в async-сессии не подгружается лениво.
created_at=now,
updated_at=now,
order_id=order.id,
cash_register_id=register.id,
created_by_id=user.id,
waybill_number=order.waybill_number,
total_kopecks=amounts.total_kopecks,
prepayment_kopecks=amounts.prepayment_kopecks,
cod_kopecks=amounts.cod_kopecks,
status=ReceiptStatus.PENDING,
request_body=body,
)
session.add(receipt)
order.receipt_created_at = now
busy.add(order_id)
await audit.record(
session,
action=AuditAction.RECEIPT_CREATE_REQUESTED,
user=user,
entity_type="receipt",
entity_id=receipt.id,
payload={
"order_id": order.id,
"waybill_number": order.waybill_number,
"cod_kopecks": amounts.cod_kopecks,
"prepayment_kopecks": amounts.prepayment_kopecks,
},
request=request,
)
result.created.append(receipt)
return result
async def _release_order(session: AsyncSession, order_id: str) -> None:
"""Возвращает заказ в очередь «Без чека»."""
order = await session.get(Order, order_id)
if order is not None:
order.receipt_created_at = None
def _apply_ettn(receipt: Receipt, ettn: EttnOut) -> None:
receipt.checkbox_ettn_id = ettn.id
receipt.checkbox_status = ettn.status
receipt.last_checked_at = datetime.now(UTC)
if ettn.receipt_id:
receipt.checkbox_receipt_id = ettn.receipt_id
new_status = _CHECKBOX_TO_STATUS.get(ettn.status)
if new_status is not None:
receipt.status = new_status
elif receipt.status == ReceiptStatus.PENDING:
receipt.status = ReceiptStatus.CREATED
receipt.error = ettn.raw_error if new_status == ReceiptStatus.RECEIPT_ERROR else None
async def create_ettn_for_receipt(
session: AsyncSession, client: CheckboxClient, receipt_id: uuid.UUID
) -> Receipt | None:
"""Отправляет `pending`-чек в Checkbox. Идемпотентна — безопасно вызывать повторно."""
receipt = await session.get(Receipt, receipt_id, with_for_update=True)
if receipt is None or receipt.status != ReceiptStatus.PENDING:
return receipt
register = await session.get(CashRegister, receipt.cash_register_id)
assert register is not None # FK
creds = credentials(register)
try:
# Непустой `error` у pending-чека — прошлая попытка закончилась
# неизвестным исходом: сначала ищем, не создан ли чек уже.
existing = (
await client.find_ettn(creds, receipt.waybill_number) if receipt.error else None
)
ettn = existing or await client.create_ettn(creds, receipt.request_body)
except CheckboxUnavailableError as exc:
receipt.error = str(exc)
await session.commit()
log.warning("checkbox_unavailable", receipt_id=str(receipt.id), error=str(exc))
return receipt
except CheckboxError as exc:
receipt.status = ReceiptStatus.FAILED
receipt.error = str(exc)
await _release_order(session, receipt.order_id)
await session.commit()
log.warning("ettn_create_failed", receipt_id=str(receipt.id), error=str(exc))
return receipt
_apply_ettn(receipt, ettn)
if receipt.status in CLOSED_STATUSES:
await _release_order(session, receipt.order_id)
await session.commit()
log.info("ettn_created", receipt_id=str(receipt.id), ettn_id=ettn.id)
return receipt
async def retry_pending_receipts(session: AsyncSession, client: CheckboxClient) -> None:
"""Повторяет зависшие `pending`-чеки (потерянная задача, недоступный Checkbox)."""
threshold = datetime.now(UTC) - _PENDING_RETRY_AFTER
receipt_ids = list(
await session.scalars(
select(Receipt.id)
.where(Receipt.status == ReceiptStatus.PENDING)
.where(Receipt.updated_at < threshold)
)
)
for receipt_id in receipt_ids:
await create_ettn_for_receipt(session, client, receipt_id)
# --- Отмена и опрос ----------------------------------------------------------
class ReceiptStateError(Exception):
"""Действие недопустимо в текущем статусе чека."""
async def cancel_receipt(
session: AsyncSession,
client: CheckboxClient,
receipt_id: uuid.UUID,
*,
user: User,
request: Request | None = None,
) -> Receipt | None:
"""Удаляет ЕТТН-чек в Checkbox и возвращает заказ в очередь. Коммитит сам."""
receipt = await session.get(Receipt, receipt_id, with_for_update=True)
if receipt is None:
return None
if receipt.status not in (ReceiptStatus.CREATED, ReceiptStatus.RECEIPT_ERROR):
raise ReceiptStateError(f"Чек в статусе «{receipt.status}» отменить нельзя")
if receipt.checkbox_ettn_id:
register = await session.get(CashRegister, receipt.cash_register_id)
assert register is not None
await client.delete_ettn(credentials(register), receipt.checkbox_ettn_id)
receipt.status = ReceiptStatus.CANCELLED
receipt.checkbox_status = EttnStatus.CANCELLED
await _release_order(session, receipt.order_id)
await audit.record(
session,
action=AuditAction.RECEIPT_CANCELLED,
user=user,
entity_type="receipt",
entity_id=receipt.id,
payload={"order_id": receipt.order_id, "waybill_number": receipt.waybill_number},
request=request,
)
await session.commit()
return receipt
async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> None:
"""Опрашивает Checkbox по чекам, ожидающим оплаты посылки (`created`)."""
receipts = list(
await session.scalars(select(Receipt).where(Receipt.status == ReceiptStatus.CREATED))
)
registers: dict[uuid.UUID, CheckboxCredentials] = {}
for receipt in receipts:
if not receipt.checkbox_ettn_id:
continue
creds = registers.get(receipt.cash_register_id)
if creds is None:
register = await session.get(CashRegister, receipt.cash_register_id)
assert register is not None
creds = registers[receipt.cash_register_id] = credentials(register)
try:
ettn = await client.get_ettn(creds, receipt.checkbox_ettn_id)
except CheckboxError as exc:
log.warning("ettn_poll_failed", receipt_id=str(receipt.id), error=str(exc))
continue
_apply_ettn(receipt, ettn)
if receipt.status in CLOSED_STATUSES:
await _release_order(session, receipt.order_id)
await session.commit()
async def latest_receipts_by_order(
session: AsyncSession, order_ids: list[str]
) -> dict[str, Receipt]:
"""Последний чек по каждому заказу — для колонок очереди."""
if not order_ids:
return {}
receipts = await session.scalars(
select(Receipt).where(Receipt.order_id.in_(order_ids)).order_by(Receipt.created_at)
)
return {receipt.order_id: receipt for receipt in receipts}
+53
View File
@@ -0,0 +1,53 @@
"""Постановка задач ARQ worker'у из API.
Пул Redis создаётся лениво при первой задаче, а не в lifespan: недоступный
Redis не должен ронять старт API. Если задачу поставить не удалось, чек
остаётся `pending`, и cron `poll_receipts` подхватит его в течение минуты.
"""
from __future__ import annotations
from typing import Any, Protocol
from arq import ArqRedis, create_pool
from arq.connections import RedisSettings
from app.core.config import settings
from app.core.logging import get_logger
log = get_logger(__name__)
class TaskQueue(Protocol):
async def enqueue(self, function: str, *args: Any) -> None: ...
class ArqTaskQueue:
def __init__(self) -> None:
self._pool: ArqRedis | None = None
async def enqueue(self, function: str, *args: Any) -> None:
try:
if self._pool is None:
self._pool = await create_pool(
RedisSettings.from_dsn(settings.redis_url), retry=0
)
await self._pool.enqueue_job(function, *args)
except Exception as exc: # noqa: BLE001 — см. докстринг модуля
log.warning("enqueue_failed", function=function, error=repr(exc))
async def close(self) -> None:
if self._pool is not None:
await self._pool.aclose()
self._pool = None
_queue = ArqTaskQueue()
def get_task_queue() -> TaskQueue:
return _queue
async def close_task_queue() -> None:
await _queue.close()