Each cash register stores its own encrypted NP API key. Status polling uses register keys and binds an order to the register whose key sees the TTN as its own (PhoneSender present); ETTN receipts are created from that register. Migration 0008 moves the old NOVA_POSHTA_API_KEY into the default register. Closes #3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
5.8 KiB
Python
149 lines
5.8 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)
|
|
)
|
|
|
|
|
|
async def _ensure_np_key_unique(
|
|
session: SessionDep, api_key: str, exclude_id: uuid.UUID | None = None
|
|
) -> None:
|
|
"""Один кабинет НП — одна касса, иначе неясно, от какой кассы пробивать его посылки."""
|
|
registers = await session.scalars(
|
|
select(CashRegister).where(CashRegister.np_api_key_enc.is_not(None))
|
|
)
|
|
for register in registers:
|
|
if register.id == exclude_id:
|
|
continue
|
|
try:
|
|
same = crypto.decrypt(register.np_api_key_enc) == api_key
|
|
except crypto.DecryptionError:
|
|
continue
|
|
if same:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Этот ключ Новой Почты уже привязан к кассе «{register.name}»",
|
|
)
|
|
|
|
|
|
@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:
|
|
if payload.np_api_key:
|
|
await _ensure_np_key_unique(session, payload.np_api_key)
|
|
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),
|
|
np_api_key_enc=crypto.encrypt(payload.np_api_key) if payload.np_api_key else None,
|
|
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,
|
|
"np_api_key_set": register.np_api_key_enc is not None,
|
|
},
|
|
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 changes.get("np_api_key"):
|
|
await _ensure_np_key_unique(session, changes["np_api_key"], exclude_id=register.id)
|
|
register.np_api_key_enc = crypto.encrypt(changes["np_api_key"])
|
|
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 / np_api_key редактируются в 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
|