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
+117
View File
@@ -0,0 +1,117 @@
"""Кассы Checkbox. Только администратор; секреты — только на запись."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select, update
from app.api.deps import AdminUser, CheckboxClientDep, SessionDep, require_admin
from app.core import crypto
from app.db.models.audit import AuditAction
from app.db.models.cash_register import CashRegister
from app.schemas.receipts import CashRegisterCreate, CashRegisterOut, CashRegisterUpdate
from app.services import audit
from app.services.checkbox.client import CheckboxError
from app.services.receipts import credentials
router = APIRouter(
prefix="/cash-registers", tags=["cash-registers"], dependencies=[Depends(require_admin)]
)
async def _clear_default(session: SessionDep, keep_id: uuid.UUID) -> None:
await session.execute(
update(CashRegister).where(CashRegister.id != keep_id).values(is_default=False)
)
@router.get("", response_model=list[CashRegisterOut])
async def list_cash_registers(session: SessionDep) -> list[CashRegisterOut]:
registers = await session.scalars(select(CashRegister).order_by(CashRegister.created_at))
return [CashRegisterOut.from_register(register) for register in registers]
@router.post("", response_model=CashRegisterOut, status_code=status.HTTP_201_CREATED)
async def create_cash_register(
payload: CashRegisterCreate, request: Request, session: SessionDep, actor: AdminUser
) -> CashRegisterOut:
register = CashRegister(
id=uuid.uuid4(),
name=payload.name,
fiscal_number=payload.fiscal_number,
license_key_enc=crypto.encrypt(payload.license_key),
cashier_pin_enc=crypto.encrypt(payload.pin_code),
tax_codes=payload.tax_codes,
is_active=True,
is_default=payload.is_default,
)
if register.is_default:
await _clear_default(session, register.id)
session.add(register)
await audit.record(
session,
action=AuditAction.CASH_REGISTER_CREATED,
user=actor,
entity_type="cash_register",
entity_id=register.id,
payload={"name": register.name, "fiscal_number": register.fiscal_number},
request=request,
)
await session.commit()
return CashRegisterOut.from_register(register)
@router.patch("/{register_id}", response_model=CashRegisterOut)
async def update_cash_register(
register_id: uuid.UUID,
payload: CashRegisterUpdate,
request: Request,
session: SessionDep,
actor: AdminUser,
) -> CashRegisterOut:
register = await session.get(CashRegister, register_id)
if register is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Касса не найдена")
changes = payload.model_dump(exclude_unset=True)
if "license_key" in changes:
register.license_key_enc = crypto.encrypt(changes["license_key"])
if "pin_code" in changes:
register.cashier_pin_enc = crypto.encrypt(changes["pin_code"])
if "fiscal_number" in changes:
register.fiscal_number = changes["fiscal_number"]
for field in ("name", "tax_codes", "is_active", "is_default"):
if changes.get(field) is not None:
setattr(register, field, changes[field])
if changes.get("is_default"):
await _clear_default(session, register.id)
await audit.record(
session,
action=AuditAction.CASH_REGISTER_UPDATED,
user=actor,
entity_type="cash_register",
entity_id=register.id,
payload=changes, # license_key / pin_code редактируются в audit._sanitize
request=request,
)
await session.commit()
return CashRegisterOut.from_register(register)
@router.post("/{register_id}/check", status_code=status.HTTP_204_NO_CONTENT)
async def check_cash_register(
register_id: uuid.UUID, session: SessionDep, checkbox: CheckboxClientDep
) -> None:
"""Пробный вход кассира в Checkbox — проверка ключа лицензии и PIN."""
register = await session.get(CashRegister, register_id)
if register is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Касса не найдена")
try:
await checkbox.sign_in(credentials(register))
except (CheckboxError, crypto.DecryptionError) as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Checkbox: {exc}"
) from exc
+5 -1
View File
@@ -9,6 +9,7 @@ from app.db.models.audit import AuditAction
from app.schemas.orders import OrderRowOut
from app.services import audit
from app.services import orders as orders_service
from app.services import receipts as receipts_service
router = APIRouter(prefix="/orders", tags=["orders"], dependencies=[Depends(require_any)])
@@ -21,7 +22,10 @@ async def list_orders(
# CRM-алиасы (SKU/Name/...) вместо snake_case полей, которые ждёт фронтенд.
await orders_service.sync_orders_from_crm(session, crm)
orders = await orders_service.list_orders(session, has_receipt=has_receipt)
return [OrderRowOut.from_order(order) for order in orders]
receipts = await receipts_service.latest_receipts_by_order(
session, [order.id for order in orders]
)
return [OrderRowOut.from_order(order, receipts.get(order.id)) for order in orders]
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
+87
View File
@@ -0,0 +1,87 @@
"""ЕТТН-чеки Checkbox: запрос создания (массово), просмотр, отмена."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from app.api.deps import (
CashierUser,
CheckboxClientDep,
SessionDep,
TaskQueueDep,
require_any,
)
from app.db.models.receipt import Receipt
from app.schemas.receipts import ReceiptCreateRequest, ReceiptCreateResponse, ReceiptOut
from app.services import receipts as receipts_service
from app.services.checkbox.client import CheckboxError
router = APIRouter(prefix="/receipts", tags=["receipts"], dependencies=[Depends(require_any)])
@router.post("", response_model=ReceiptCreateResponse, status_code=status.HTTP_202_ACCEPTED)
async def create_receipts(
payload: ReceiptCreateRequest,
request: Request,
session: SessionDep,
queue: TaskQueueDep,
actor: CashierUser,
) -> ReceiptCreateResponse:
result = await receipts_service.request_receipts(
session,
[(item.order_id, item.prepayment_kopecks) for item in payload.items],
user=actor,
request=request,
)
await session.commit()
# После commit: worker должен увидеть строку `pending`.
for receipt in result.created:
await queue.enqueue("create_ettn_receipt", str(receipt.id))
return ReceiptCreateResponse(
created=[ReceiptOut.from_receipt(receipt) for receipt in result.created],
errors=result.errors,
)
@router.get("", response_model=list[ReceiptOut])
async def list_receipts(session: SessionDep, order_id: str | None = None) -> list[ReceiptOut]:
query = select(Receipt).order_by(Receipt.created_at.desc()).limit(500)
if order_id is not None:
query = query.where(Receipt.order_id == order_id)
return [ReceiptOut.from_receipt(receipt) for receipt in await session.scalars(query)]
@router.get("/{receipt_id}", response_model=ReceiptOut)
async def get_receipt(receipt_id: uuid.UUID, session: SessionDep) -> ReceiptOut:
receipt = await session.get(Receipt, receipt_id)
if receipt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Чек не найден")
return ReceiptOut.from_receipt(receipt)
@router.post("/{receipt_id}/cancel", response_model=ReceiptOut)
async def cancel_receipt(
receipt_id: uuid.UUID,
request: Request,
session: SessionDep,
checkbox: CheckboxClientDep,
actor: CashierUser,
) -> ReceiptOut:
try:
receipt = await receipts_service.cancel_receipt(
session, checkbox, receipt_id, user=actor, request=request
)
except receipts_service.ReceiptStateError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except CheckboxError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Checkbox: {exc}"
) from exc
if receipt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Чек не найден")
return ReceiptOut.from_receipt(receipt)
+3 -1
View File
@@ -2,10 +2,12 @@
from fastapi import APIRouter
from app.api.v1 import auth, health, orders, users
from app.api.v1 import auth, cash_registers, health, orders, receipts, users
api_router = APIRouter()
api_router.include_router(health.router)
api_router.include_router(auth.router)
api_router.include_router(users.router)
api_router.include_router(orders.router)
api_router.include_router(receipts.router)
api_router.include_router(cash_registers.router)