Files
lux_fiscal/backend/app/api/v1/cash_registers.py
T
lauadminandClaude Opus 5.5 518a99197f 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>
2026-09-23 23:39:00 +03:00

118 lines
4.5 KiB
Python

"""Кассы 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