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
+5
View File
@@ -6,14 +6,19 @@ Alembic автогенерирует миграции по `Base.metadata`, по
from app.db.base import Base
from app.db.models.audit import AuditAction, AuditLog
from app.db.models.cash_register import CashRegister
from app.db.models.order import Order
from app.db.models.receipt import Receipt, ReceiptStatus
from app.db.models.user import RefreshToken, User, UserRole
__all__ = [
"AuditAction",
"AuditLog",
"Base",
"CashRegister",
"Order",
"Receipt",
"ReceiptStatus",
"RefreshToken",
"User",
"UserRole",
+4
View File
@@ -32,6 +32,10 @@ class AuditAction(str):
USER_UPDATED = "user.updated"
USER_DEACTIVATED = "user.deactivated"
ORDER_DELETED = "order.deleted"
CASH_REGISTER_CREATED = "cash_register.created"
CASH_REGISTER_UPDATED = "cash_register.updated"
RECEIPT_CREATE_REQUESTED = "receipt.create_requested"
RECEIPT_CANCELLED = "receipt.cancelled"
class AuditLog(UUIDPrimaryKeyMixin, Base):
+39
View File
@@ -0,0 +1,39 @@
"""Кассы (ПРРО) Checkbox.
Ключ лицензии и PIN кассира хранятся только в зашифрованном виде
(`app/core/crypto.py`): утечка дампа БД не должна давать доступ к кассе.
"""
from __future__ import annotations
from typing import Any
from sqlalchemy import Boolean, Index, String, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class CashRegister(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "cash_registers"
name: Mapped[str] = mapped_column(String(255), nullable=False)
fiscal_number: Mapped[str | None] = mapped_column(String(64))
license_key_enc: Mapped[str] = mapped_column(String(512), nullable=False)
cashier_pin_enc: Mapped[str] = mapped_column(String(512), nullable=False)
# Коды налоговых ставок Checkbox для всех товаров чека; пусто — поле `tax`
# не передаётся (неплательщик ПДВ).
tax_codes: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
__table_args__ = (
# Касса по умолчанию может быть только одна.
Index(
"uq_cash_registers_default",
"is_default",
unique=True,
postgresql_where=text("is_default"),
),
)
+83
View File
@@ -0,0 +1,83 @@
"""ЕТТН-чеки Checkbox по ТТН Nova Poshta с контролем оплаты.
Чек создаётся в Checkbox как шаблон, привязанный к ТТН; фискализирует его сам
Checkbox, когда клиент оплачивает посылку в отделении. Здесь — наша сторона:
кто и когда запросил, что отправили и в каком статусе чек сейчас.
Машина состояний:
pending ──► created ──► done | returned | receipt_error | cancelled
└──────► failed
created ──(отмена кассиром)──► cancelled
Из `failed`/`cancelled` заказ возвращается в очередь и чек можно создать заново.
"""
from __future__ import annotations
import enum
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, Index, String, Text, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class ReceiptStatus(enum.StrEnum):
PENDING = "pending" # запись создана, задача в очереди worker'а
CREATED = "created" # Checkbox принял ЕТТН-чек, ждём оплату посылки
DONE = "done" # Checkbox фискализировал чек
RETURNED = "returned" # посылка вернулась отправителю
RECEIPT_ERROR = "receipt_error" # Checkbox не смог фискализировать
CANCELLED = "cancelled" # отменён (кассиром или в Checkbox)
FAILED = "failed" # Checkbox отклонил создание
# Статусы, при которых у заказа нет «живого» чека и его можно создать заново.
CLOSED_STATUSES = (ReceiptStatus.CANCELLED, ReceiptStatus.FAILED)
class Receipt(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "receipts"
order_id: Mapped[str] = mapped_column(
ForeignKey("orders.id", ondelete="RESTRICT"), index=True, nullable=False
)
cash_register_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("cash_registers.id", ondelete="RESTRICT"), index=True, nullable=False
)
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL")
)
waybill_number: Mapped[str] = mapped_column(String(64), nullable=False)
total_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
prepayment_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
cod_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
status: Mapped[ReceiptStatus] = mapped_column(
Enum(ReceiptStatus, name="receipt_status", values_callable=lambda e: [i.value for i in e]),
nullable=False,
default=ReceiptStatus.PENDING,
index=True,
)
checkbox_ettn_id: Mapped[str | None] = mapped_column(String(64), index=True)
checkbox_status: Mapped[str | None] = mapped_column(String(32))
checkbox_receipt_id: Mapped[str | None] = mapped_column(String(64))
error: Mapped[str | None] = mapped_column(Text)
# Снимок отправленного в Checkbox тела — для разбора спорных случаев.
request_body: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (
# Не больше одного «живого» чека на заказ — защита от двойного нажатия
# и гонки между кассирами на уровне БД.
Index(
"uq_receipts_active_order",
"order_id",
unique=True,
postgresql_where=text("status NOT IN ('cancelled', 'failed')"),
),
)