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:
@@ -0,0 +1,490 @@
|
||||
"""ЕТТН-чеки Checkbox: создание по ТТН с контролем оплаты, отмена, опрос статусов.
|
||||
|
||||
Чек не фискализируется нами — Checkbox делает это сам, когда клиент оплачивает
|
||||
посылку в отделении НП. Поэтому ЕТТН-чек должен быть создан до получения посылки,
|
||||
а сумма к оплате (наложка НП) обязана совпасть с суммой чека:
|
||||
|
||||
сумма товаров − скидки − предоплата == np_cod_amount_kopecks
|
||||
|
||||
Создание двухфазное: API пишет `Receipt(pending)` (+ аудит) и ставит задачу,
|
||||
worker вызывает Checkbox (`create_ettn_for_receipt`). Так кассир не ждёт сеть,
|
||||
а сбой Checkbox не теряет запрос — `retry_pending_receipts` его повторит.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core import crypto
|
||||
from app.core.logging import get_logger
|
||||
from app.db.models.audit import AuditAction
|
||||
from app.db.models.cash_register import CashRegister
|
||||
from app.db.models.order import Order
|
||||
from app.db.models.receipt import CLOSED_STATUSES, Receipt, ReceiptStatus
|
||||
from app.db.models.user import User
|
||||
from app.schemas.checkbox import EttnOut, EttnStatus
|
||||
from app.services import audit
|
||||
from app.services.checkbox.client import (
|
||||
CheckboxClient,
|
||||
CheckboxCredentials,
|
||||
CheckboxError,
|
||||
CheckboxUnavailableError,
|
||||
)
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# Коды статусов НП, при которых ЕТТН-чек создавать поздно или бессмысленно:
|
||||
# посылка уже получена, возвращается/возвращена или ТТН удалена.
|
||||
NP_FINAL_STATUS_CODES = frozenset(
|
||||
{"2", "9", "10", "11", "102", "103", "105", "106", "108"}
|
||||
)
|
||||
|
||||
# Статус Checkbox → наш статус. CREATED не меняет ничего.
|
||||
_CHECKBOX_TO_STATUS = {
|
||||
EttnStatus.DONE: ReceiptStatus.DONE,
|
||||
EttnStatus.DONE_WITHOUT_SMS: ReceiptStatus.DONE,
|
||||
EttnStatus.RETURNED: ReceiptStatus.RETURNED,
|
||||
EttnStatus.RECEIPT_ERROR: ReceiptStatus.RECEIPT_ERROR,
|
||||
EttnStatus.CANCELLED: ReceiptStatus.CANCELLED,
|
||||
}
|
||||
|
||||
# Сколько ждать, прежде чем считать `pending`-чек зависшим (задача потеряна
|
||||
# или Checkbox был недоступен) и повторить его из cron'а.
|
||||
_PENDING_RETRY_AFTER = timedelta(minutes=1)
|
||||
|
||||
|
||||
class ReceiptValidationError(Exception):
|
||||
"""Заказ нельзя отправить в Checkbox — сообщение показывается кассиру."""
|
||||
|
||||
|
||||
# --- Деньги и количества -----------------------------------------------------
|
||||
|
||||
|
||||
def to_kopecks(amount: str | None) -> int:
|
||||
if not amount:
|
||||
return 0
|
||||
return int((Decimal(amount) * 100).to_integral_value(ROUND_HALF_UP))
|
||||
|
||||
|
||||
def to_thousandths(quantity: str) -> int:
|
||||
return int((Decimal(quantity) * 1000).to_integral_value(ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _line_sum(price_kopecks: int, quantity: int) -> int:
|
||||
return int((Decimal(price_kopecks) * quantity / 1000).to_integral_value(ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _normalize_phone(phone: str | None) -> str | None:
|
||||
"""Телефон для отправки чека: Checkbox принимает только `+?380\\d{9}`."""
|
||||
if not phone:
|
||||
return None
|
||||
digits = re.sub(r"\D", "", phone)
|
||||
if len(digits) == 10 and digits.startswith("0"):
|
||||
digits = "38" + digits
|
||||
return f"+{digits}" if re.fullmatch(r"380\d{9}", digits) else None
|
||||
|
||||
|
||||
# --- Тело запроса ------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReceiptAmounts:
|
||||
total_kopecks: int # к оплате по заказу после всех скидок
|
||||
prepayment_kopecks: int
|
||||
cod_kopecks: int # наложка НП = total − prepayment
|
||||
|
||||
|
||||
def build_goods(order: Order, tax_codes: list[Any]) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Товары CRM → `goods` Checkbox. Возвращает позиции и их сумму после скидок."""
|
||||
items: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
for good in order.goods:
|
||||
price = to_kopecks(good["price"])
|
||||
quantity = to_thousandths(good["quantity"])
|
||||
if quantity <= 0:
|
||||
raise ReceiptValidationError(f"Товар «{good['name']}»: количество должно быть больше 0")
|
||||
gross = _line_sum(price, quantity)
|
||||
# `amount` — сумма строки после скидки CRM; скидку выводим из неё,
|
||||
# а не из discount_amount/percent, чтобы не расходиться с итогом CRM.
|
||||
net = to_kopecks(good.get("amount")) if good.get("amount") else gross
|
||||
discount = gross - net
|
||||
if discount < 0:
|
||||
raise ReceiptValidationError(
|
||||
f"Товар «{good['name']}»: сумма строки больше цены × количество"
|
||||
)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"code": good.get("sku") or good["id"],
|
||||
"name": good["name"],
|
||||
"price": price,
|
||||
}
|
||||
if tax_codes:
|
||||
payload["tax"] = list(tax_codes)
|
||||
item: dict[str, Any] = {"good": payload, "quantity": quantity, "is_return": False}
|
||||
if discount:
|
||||
item["discounts"] = [{"type": "DISCOUNT", "mode": "VALUE", "value": discount}]
|
||||
items.append(item)
|
||||
total += net
|
||||
return items, total
|
||||
|
||||
|
||||
def build_ettn_body(
|
||||
order: Order, register: CashRegister, amounts: ReceiptAmounts
|
||||
) -> dict[str, Any]:
|
||||
goods, goods_total = build_goods(order, register.tax_codes)
|
||||
discounts: list[dict[str, Any]] = []
|
||||
|
||||
# Скидка на весь заказ в CRM (не распределённая по строкам).
|
||||
order_discount = goods_total - amounts.total_kopecks
|
||||
if order_discount < 0:
|
||||
raise ReceiptValidationError(
|
||||
"Сумма товаров меньше суммы заказа — проверьте заказ в CRM"
|
||||
)
|
||||
if order_discount:
|
||||
discounts.append(
|
||||
{"type": "DISCOUNT", "mode": "VALUE", "value": order_discount, "name": "Знижка"}
|
||||
)
|
||||
if amounts.prepayment_kopecks:
|
||||
discounts.append(
|
||||
{
|
||||
"type": "PRE_PAYMENT",
|
||||
"mode": "VALUE",
|
||||
"value": amounts.prepayment_kopecks,
|
||||
"name": "Передоплата",
|
||||
}
|
||||
)
|
||||
|
||||
receipt_body: dict[str, Any] = {
|
||||
"goods": goods,
|
||||
"payments": [{"value": amounts.cod_kopecks, "ettn": order.waybill_number}],
|
||||
}
|
||||
if discounts:
|
||||
receipt_body["discounts"] = discounts
|
||||
|
||||
delivery: dict[str, Any] = {}
|
||||
if phone := _normalize_phone(order.recipient_phone):
|
||||
delivery["phone"] = phone
|
||||
if order.recipient_email:
|
||||
delivery["emails"] = [order.recipient_email]
|
||||
if delivery:
|
||||
receipt_body["delivery"] = delivery
|
||||
|
||||
return {"provider": "novapost", "receipt_body": receipt_body}
|
||||
|
||||
|
||||
def resolve_amounts(order: Order, prepayment_kopecks: int | None) -> ReceiptAmounts:
|
||||
"""Проверяет, что по заказу можно создать ЕТТН-чек, и считает суммы.
|
||||
|
||||
`prepayment_kopecks=None` — взять разницу между суммой заказа и наложкой.
|
||||
"""
|
||||
if order.is_deleted:
|
||||
raise ReceiptValidationError("Заказ удалён")
|
||||
if not order.waybill_number:
|
||||
raise ReceiptValidationError("У заказа нет ТТН")
|
||||
if not order.np_cod_amount_kopecks:
|
||||
raise ReceiptValidationError("По ТТН нет суммы контроля оплаты (наложки)")
|
||||
if order.np_status_code in NP_FINAL_STATUS_CODES:
|
||||
raise ReceiptValidationError(
|
||||
f"Посылка уже не в пути ({order.np_status or order.np_status_code}) — "
|
||||
"ЕТТН-чек создать нельзя"
|
||||
)
|
||||
if not order.goods:
|
||||
raise ReceiptValidationError("В заказе нет товаров")
|
||||
|
||||
total = order.total_amount_kopecks
|
||||
cod = order.np_cod_amount_kopecks
|
||||
prepayment = total - cod if prepayment_kopecks is None else prepayment_kopecks
|
||||
if prepayment < 0:
|
||||
raise ReceiptValidationError(
|
||||
f"Наложка {cod / 100:.2f} ₴ больше суммы заказа {total / 100:.2f} ₴"
|
||||
)
|
||||
if total - prepayment != cod:
|
||||
raise ReceiptValidationError(
|
||||
f"Сумма заказа {total / 100:.2f} ₴ − предоплата {prepayment / 100:.2f} ₴ "
|
||||
f"≠ наложка {cod / 100:.2f} ₴"
|
||||
)
|
||||
return ReceiptAmounts(total_kopecks=total, prepayment_kopecks=prepayment, cod_kopecks=cod)
|
||||
|
||||
|
||||
# --- Кассы -------------------------------------------------------------------
|
||||
|
||||
|
||||
def credentials(register: CashRegister) -> CheckboxCredentials:
|
||||
return CheckboxCredentials(
|
||||
cash_register_id=register.id,
|
||||
license_key=crypto.decrypt(register.license_key_enc),
|
||||
pin_code=crypto.decrypt(register.cashier_pin_enc),
|
||||
)
|
||||
|
||||
|
||||
async def get_default_register(session: AsyncSession) -> CashRegister | None:
|
||||
return await session.scalar(
|
||||
select(CashRegister)
|
||||
.where(CashRegister.is_active.is_(True))
|
||||
.order_by(CashRegister.is_default.desc(), CashRegister.created_at)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
|
||||
# --- Создание ----------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestResult:
|
||||
created: list[Receipt]
|
||||
errors: dict[str, str]
|
||||
|
||||
|
||||
async def _active_receipt_order_ids(session: AsyncSession, order_ids: list[str]) -> set[str]:
|
||||
result = await session.scalars(
|
||||
select(Receipt.order_id)
|
||||
.where(Receipt.order_id.in_(order_ids))
|
||||
.where(Receipt.status.not_in(CLOSED_STATUSES))
|
||||
)
|
||||
return set(result)
|
||||
|
||||
|
||||
async def request_receipts(
|
||||
session: AsyncSession,
|
||||
items: list[tuple[str, int | None]],
|
||||
*,
|
||||
user: User,
|
||||
request: Request | None = None,
|
||||
) -> RequestResult:
|
||||
"""Создаёт `Receipt(pending)` по каждому подходящему заказу.
|
||||
|
||||
Без commit и без обращения к Checkbox: вызывающий код коммитит и ставит
|
||||
задачи worker'у (`create_ettn_for_receipt`) по `result.created`.
|
||||
Ошибки по отдельным заказам не мешают остальным — массовое действие.
|
||||
"""
|
||||
result = RequestResult(created=[], errors={})
|
||||
register = await get_default_register(session)
|
||||
if register is None:
|
||||
for order_id, _ in items:
|
||||
result.errors[order_id] = "Не настроена касса Checkbox"
|
||||
return result
|
||||
|
||||
order_ids = [order_id for order_id, _ in items]
|
||||
orders = {
|
||||
order.id: order
|
||||
for order in await session.scalars(select(Order).where(Order.id.in_(order_ids)))
|
||||
}
|
||||
busy = await _active_receipt_order_ids(session, order_ids)
|
||||
|
||||
for order_id, prepayment in items:
|
||||
order = orders.get(order_id)
|
||||
if order is None:
|
||||
result.errors[order_id] = "Заказ не найден"
|
||||
continue
|
||||
if order_id in busy:
|
||||
result.errors[order_id] = "По заказу уже есть чек"
|
||||
continue
|
||||
try:
|
||||
amounts = resolve_amounts(order, prepayment)
|
||||
body = build_ettn_body(order, register, amounts)
|
||||
except ReceiptValidationError as exc:
|
||||
result.errors[order_id] = str(exc)
|
||||
continue
|
||||
|
||||
now = datetime.now(UTC)
|
||||
receipt = Receipt(
|
||||
id=uuid.uuid4(),
|
||||
# Явно, а не server_default: после commit ответ API читает created_at,
|
||||
# а незагруженный server_default в async-сессии не подгружается лениво.
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
order_id=order.id,
|
||||
cash_register_id=register.id,
|
||||
created_by_id=user.id,
|
||||
waybill_number=order.waybill_number,
|
||||
total_kopecks=amounts.total_kopecks,
|
||||
prepayment_kopecks=amounts.prepayment_kopecks,
|
||||
cod_kopecks=amounts.cod_kopecks,
|
||||
status=ReceiptStatus.PENDING,
|
||||
request_body=body,
|
||||
)
|
||||
session.add(receipt)
|
||||
order.receipt_created_at = now
|
||||
busy.add(order_id)
|
||||
await audit.record(
|
||||
session,
|
||||
action=AuditAction.RECEIPT_CREATE_REQUESTED,
|
||||
user=user,
|
||||
entity_type="receipt",
|
||||
entity_id=receipt.id,
|
||||
payload={
|
||||
"order_id": order.id,
|
||||
"waybill_number": order.waybill_number,
|
||||
"cod_kopecks": amounts.cod_kopecks,
|
||||
"prepayment_kopecks": amounts.prepayment_kopecks,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
result.created.append(receipt)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def _release_order(session: AsyncSession, order_id: str) -> None:
|
||||
"""Возвращает заказ в очередь «Без чека»."""
|
||||
order = await session.get(Order, order_id)
|
||||
if order is not None:
|
||||
order.receipt_created_at = None
|
||||
|
||||
|
||||
def _apply_ettn(receipt: Receipt, ettn: EttnOut) -> None:
|
||||
receipt.checkbox_ettn_id = ettn.id
|
||||
receipt.checkbox_status = ettn.status
|
||||
receipt.last_checked_at = datetime.now(UTC)
|
||||
if ettn.receipt_id:
|
||||
receipt.checkbox_receipt_id = ettn.receipt_id
|
||||
new_status = _CHECKBOX_TO_STATUS.get(ettn.status)
|
||||
if new_status is not None:
|
||||
receipt.status = new_status
|
||||
elif receipt.status == ReceiptStatus.PENDING:
|
||||
receipt.status = ReceiptStatus.CREATED
|
||||
receipt.error = ettn.raw_error if new_status == ReceiptStatus.RECEIPT_ERROR else None
|
||||
|
||||
|
||||
async def create_ettn_for_receipt(
|
||||
session: AsyncSession, client: CheckboxClient, receipt_id: uuid.UUID
|
||||
) -> Receipt | None:
|
||||
"""Отправляет `pending`-чек в Checkbox. Идемпотентна — безопасно вызывать повторно."""
|
||||
receipt = await session.get(Receipt, receipt_id, with_for_update=True)
|
||||
if receipt is None or receipt.status != ReceiptStatus.PENDING:
|
||||
return receipt
|
||||
|
||||
register = await session.get(CashRegister, receipt.cash_register_id)
|
||||
assert register is not None # FK
|
||||
creds = credentials(register)
|
||||
|
||||
try:
|
||||
# Непустой `error` у pending-чека — прошлая попытка закончилась
|
||||
# неизвестным исходом: сначала ищем, не создан ли чек уже.
|
||||
existing = (
|
||||
await client.find_ettn(creds, receipt.waybill_number) if receipt.error else None
|
||||
)
|
||||
ettn = existing or await client.create_ettn(creds, receipt.request_body)
|
||||
except CheckboxUnavailableError as exc:
|
||||
receipt.error = str(exc)
|
||||
await session.commit()
|
||||
log.warning("checkbox_unavailable", receipt_id=str(receipt.id), error=str(exc))
|
||||
return receipt
|
||||
except CheckboxError as exc:
|
||||
receipt.status = ReceiptStatus.FAILED
|
||||
receipt.error = str(exc)
|
||||
await _release_order(session, receipt.order_id)
|
||||
await session.commit()
|
||||
log.warning("ettn_create_failed", receipt_id=str(receipt.id), error=str(exc))
|
||||
return receipt
|
||||
|
||||
_apply_ettn(receipt, ettn)
|
||||
if receipt.status in CLOSED_STATUSES:
|
||||
await _release_order(session, receipt.order_id)
|
||||
await session.commit()
|
||||
log.info("ettn_created", receipt_id=str(receipt.id), ettn_id=ettn.id)
|
||||
return receipt
|
||||
|
||||
|
||||
async def retry_pending_receipts(session: AsyncSession, client: CheckboxClient) -> None:
|
||||
"""Повторяет зависшие `pending`-чеки (потерянная задача, недоступный Checkbox)."""
|
||||
threshold = datetime.now(UTC) - _PENDING_RETRY_AFTER
|
||||
receipt_ids = list(
|
||||
await session.scalars(
|
||||
select(Receipt.id)
|
||||
.where(Receipt.status == ReceiptStatus.PENDING)
|
||||
.where(Receipt.updated_at < threshold)
|
||||
)
|
||||
)
|
||||
for receipt_id in receipt_ids:
|
||||
await create_ettn_for_receipt(session, client, receipt_id)
|
||||
|
||||
|
||||
# --- Отмена и опрос ----------------------------------------------------------
|
||||
|
||||
|
||||
class ReceiptStateError(Exception):
|
||||
"""Действие недопустимо в текущем статусе чека."""
|
||||
|
||||
|
||||
async def cancel_receipt(
|
||||
session: AsyncSession,
|
||||
client: CheckboxClient,
|
||||
receipt_id: uuid.UUID,
|
||||
*,
|
||||
user: User,
|
||||
request: Request | None = None,
|
||||
) -> Receipt | None:
|
||||
"""Удаляет ЕТТН-чек в Checkbox и возвращает заказ в очередь. Коммитит сам."""
|
||||
receipt = await session.get(Receipt, receipt_id, with_for_update=True)
|
||||
if receipt is None:
|
||||
return None
|
||||
if receipt.status not in (ReceiptStatus.CREATED, ReceiptStatus.RECEIPT_ERROR):
|
||||
raise ReceiptStateError(f"Чек в статусе «{receipt.status}» отменить нельзя")
|
||||
|
||||
if receipt.checkbox_ettn_id:
|
||||
register = await session.get(CashRegister, receipt.cash_register_id)
|
||||
assert register is not None
|
||||
await client.delete_ettn(credentials(register), receipt.checkbox_ettn_id)
|
||||
|
||||
receipt.status = ReceiptStatus.CANCELLED
|
||||
receipt.checkbox_status = EttnStatus.CANCELLED
|
||||
await _release_order(session, receipt.order_id)
|
||||
await audit.record(
|
||||
session,
|
||||
action=AuditAction.RECEIPT_CANCELLED,
|
||||
user=user,
|
||||
entity_type="receipt",
|
||||
entity_id=receipt.id,
|
||||
payload={"order_id": receipt.order_id, "waybill_number": receipt.waybill_number},
|
||||
request=request,
|
||||
)
|
||||
await session.commit()
|
||||
return receipt
|
||||
|
||||
|
||||
async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> None:
|
||||
"""Опрашивает Checkbox по чекам, ожидающим оплаты посылки (`created`)."""
|
||||
receipts = list(
|
||||
await session.scalars(select(Receipt).where(Receipt.status == ReceiptStatus.CREATED))
|
||||
)
|
||||
registers: dict[uuid.UUID, CheckboxCredentials] = {}
|
||||
for receipt in receipts:
|
||||
if not receipt.checkbox_ettn_id:
|
||||
continue
|
||||
creds = registers.get(receipt.cash_register_id)
|
||||
if creds is None:
|
||||
register = await session.get(CashRegister, receipt.cash_register_id)
|
||||
assert register is not None
|
||||
creds = registers[receipt.cash_register_id] = credentials(register)
|
||||
try:
|
||||
ettn = await client.get_ettn(creds, receipt.checkbox_ettn_id)
|
||||
except CheckboxError as exc:
|
||||
log.warning("ettn_poll_failed", receipt_id=str(receipt.id), error=str(exc))
|
||||
continue
|
||||
_apply_ettn(receipt, ettn)
|
||||
if receipt.status in CLOSED_STATUSES:
|
||||
await _release_order(session, receipt.order_id)
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def latest_receipts_by_order(
|
||||
session: AsyncSession, order_ids: list[str]
|
||||
) -> dict[str, Receipt]:
|
||||
"""Последний чек по каждому заказу — для колонок очереди."""
|
||||
if not order_ids:
|
||||
return {}
|
||||
receipts = await session.scalars(
|
||||
select(Receipt).where(Receipt.order_id.in_(order_ids)).order_by(Receipt.created_at)
|
||||
)
|
||||
return {receipt.order_id: receipt for receipt in receipts}
|
||||
Reference in New Issue
Block a user