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:
@@ -14,8 +14,10 @@ from app.core.config import settings
|
||||
from app.core.security import TokenError, decode_access_token
|
||||
from app.db.models.user import User, UserRole
|
||||
from app.db.session import get_session
|
||||
from app.services.checkbox.client import CheckboxClient, get_checkbox_client
|
||||
from app.services.crm.client import CrmClient
|
||||
from app.services.crm.exo_client import ExoCrmClient
|
||||
from app.services.task_queue import TaskQueue, get_task_queue
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
@@ -95,3 +97,7 @@ def get_crm_client() -> CrmClient:
|
||||
|
||||
|
||||
CrmClientDep = Annotated[CrmClient, Depends(get_crm_client)]
|
||||
|
||||
|
||||
CheckboxClientDep = Annotated[CheckboxClient, Depends(get_checkbox_client)]
|
||||
TaskQueueDep = Annotated[TaskQueue, Depends(get_task_queue)]
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user