"""ЕТТН-чеки 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')"), ), )