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,32 @@
|
||||
"""Схемы Checkbox API для ЕТТН-чеков (`/api/v1/ettn`).
|
||||
|
||||
Тело запроса собирается как обычный dict в `app/services/receipts.py` —
|
||||
его снимок сохраняется в `receipts.request_body` без преобразований.
|
||||
Здесь — только разбор ответа (`BaseEttnResponse`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class EttnStatus:
|
||||
"""Статусы ЕТТН-чека в Checkbox (`OrderStatus`)."""
|
||||
|
||||
CREATED = "CREATED"
|
||||
CANCELLED = "CANCELLED"
|
||||
RECEIPT_ERROR = "RECEIPT_ERROR"
|
||||
DONE = "DONE"
|
||||
DONE_WITHOUT_SMS = "DONE_WITHOUT_SMS"
|
||||
RETURNED = "RETURNED"
|
||||
|
||||
|
||||
class EttnOut(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
id: str
|
||||
status: str
|
||||
ettn_number: str = Field(alias="ettnNumber")
|
||||
total_sum: int | None = Field(default=None, alias="totalSum")
|
||||
receipt_id: str | None = Field(default=None, alias="receiptId")
|
||||
raw_error: str | None = Field(default=None, alias="rawError")
|
||||
@@ -16,6 +16,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.db.models.order import Order
|
||||
from app.db.models.receipt import Receipt
|
||||
|
||||
|
||||
class OrderGoodOut(BaseModel):
|
||||
@@ -82,9 +83,14 @@ class OrderRowOut(BaseModel):
|
||||
np_status_code: str | None
|
||||
np_cod_amount: str | None
|
||||
np_payment_status: str | None
|
||||
# Последний ЕТТН-чек по заказу (в т.ч. отменённый/неудачный — для показа причины).
|
||||
receipt_id: str | None = None
|
||||
receipt_status: str | None = None
|
||||
receipt_error: str | None = None
|
||||
receipt_prepayment: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_order(cls, order: Order) -> OrderRowOut:
|
||||
def from_order(cls, order: Order, receipt: Receipt | None = None) -> OrderRowOut:
|
||||
return cls(
|
||||
id=order.id,
|
||||
create_date_time=order.create_date_time,
|
||||
@@ -104,4 +110,10 @@ class OrderRowOut(BaseModel):
|
||||
else None
|
||||
),
|
||||
np_payment_status=order.np_payment_status,
|
||||
receipt_id=str(receipt.id) if receipt else None,
|
||||
receipt_status=receipt.status.value if receipt else None,
|
||||
receipt_error=receipt.error if receipt else None,
|
||||
receipt_prepayment=(
|
||||
f"{receipt.prepayment_kopecks / 100:.2f}" if receipt else None
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Схемы API ЕТТН-чеков и касс Checkbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.core import crypto
|
||||
from app.db.models.cash_register import CashRegister
|
||||
from app.db.models.receipt import Receipt, ReceiptStatus
|
||||
|
||||
|
||||
def _money(kopecks: int) -> str:
|
||||
return f"{kopecks / 100:.2f}"
|
||||
|
||||
|
||||
class ReceiptOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
order_id: str
|
||||
waybill_number: str
|
||||
status: ReceiptStatus
|
||||
total_amount: str
|
||||
prepayment_amount: str
|
||||
cod_amount: str
|
||||
checkbox_ettn_id: str | None
|
||||
checkbox_status: str | None
|
||||
checkbox_receipt_id: str | None
|
||||
error: str | None
|
||||
created_at: datetime
|
||||
last_checked_at: datetime | None
|
||||
|
||||
@classmethod
|
||||
def from_receipt(cls, receipt: Receipt) -> ReceiptOut:
|
||||
return cls(
|
||||
id=receipt.id,
|
||||
order_id=receipt.order_id,
|
||||
waybill_number=receipt.waybill_number,
|
||||
status=receipt.status,
|
||||
total_amount=_money(receipt.total_kopecks),
|
||||
prepayment_amount=_money(receipt.prepayment_kopecks),
|
||||
cod_amount=_money(receipt.cod_kopecks),
|
||||
checkbox_ettn_id=receipt.checkbox_ettn_id,
|
||||
checkbox_status=receipt.checkbox_status,
|
||||
checkbox_receipt_id=receipt.checkbox_receipt_id,
|
||||
error=receipt.error,
|
||||
created_at=receipt.created_at,
|
||||
last_checked_at=receipt.last_checked_at,
|
||||
)
|
||||
|
||||
|
||||
class ReceiptRequestItem(BaseModel):
|
||||
order_id: str = Field(min_length=1, max_length=32)
|
||||
# None — предоплата = сумма заказа − наложка НП.
|
||||
prepayment_kopecks: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class ReceiptCreateRequest(BaseModel):
|
||||
items: list[ReceiptRequestItem] = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class ReceiptCreateResponse(BaseModel):
|
||||
created: list[ReceiptOut]
|
||||
# order_id → причина, по которой чек не создан.
|
||||
errors: dict[str, str]
|
||||
|
||||
|
||||
# --- Кассы -------------------------------------------------------------------
|
||||
|
||||
|
||||
class CashRegisterOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
fiscal_number: str | None
|
||||
license_key_masked: str
|
||||
tax_codes: list[Any]
|
||||
is_active: bool
|
||||
is_default: bool
|
||||
|
||||
@classmethod
|
||||
def from_register(cls, register: CashRegister) -> CashRegisterOut:
|
||||
try:
|
||||
masked = crypto.mask(crypto.decrypt(register.license_key_enc))
|
||||
except crypto.DecryptionError:
|
||||
masked = "не расшифровывается — введите заново"
|
||||
return cls(
|
||||
id=register.id,
|
||||
name=register.name,
|
||||
fiscal_number=register.fiscal_number,
|
||||
license_key_masked=masked,
|
||||
tax_codes=register.tax_codes,
|
||||
is_active=register.is_active,
|
||||
is_default=register.is_default,
|
||||
)
|
||||
|
||||
|
||||
class CashRegisterCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
fiscal_number: str | None = Field(default=None, max_length=64)
|
||||
license_key: str = Field(min_length=1, max_length=255)
|
||||
pin_code: str = Field(min_length=1, max_length=32)
|
||||
tax_codes: list[int | str] = Field(default_factory=list, max_length=2)
|
||||
is_default: bool = True
|
||||
|
||||
|
||||
class CashRegisterUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
fiscal_number: str | None = Field(default=None, max_length=64)
|
||||
license_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
pin_code: str | None = Field(default=None, min_length=1, max_length=32)
|
||||
tax_codes: list[int | str] | None = Field(default=None, max_length=2)
|
||||
is_active: bool | None = None
|
||||
is_default: bool | None = None
|
||||
Reference in New Issue
Block a user