- All frontend pages, labels, notices and errors; html lang=uk, uk-UA money format - Brand "Assistant System" in the top bar and page title - Backend error details returned to the UI (auth, orders, receipts, cash registers, Checkbox/CRM/NP errors) and CLI output - Tests updated for the new messages 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
|