Payments now carry type=ETTN / label=Експрес-накладна instead of relying on Checkbox defaults; body build fails fast if goods - discounts - prepayment does not equal the COD amount. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
513 lines
21 KiB
Python
513 lines
21 KiB
Python
"""ЕТТН-чеки 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"}
|
||
)
|
||
|
||
# Способ оплаты в ЕТТН-чеке (`ETTNPaymentSchema`).
|
||
ETTN_PAYMENT_TYPE = "ETTN"
|
||
ETTN_PAYMENT_LABEL = "Експрес-накладна"
|
||
|
||
# Статус 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": "Передоплата",
|
||
}
|
||
)
|
||
|
||
# Последняя страховка перед отправкой: сумма чека (товары − скидки − предоплата)
|
||
# обязана совпасть с оплатой по ЕТТН, иначе Checkbox не сможет фискализировать.
|
||
receipt_total = goods_total - order_discount - amounts.prepayment_kopecks
|
||
if receipt_total != amounts.cod_kopecks:
|
||
raise ReceiptValidationError(
|
||
f"Сумма чека {receipt_total / 100:.2f} ₴ ≠ наложка {amounts.cod_kopecks / 100:.2f} ₴"
|
||
)
|
||
|
||
receipt_body: dict[str, Any] = {
|
||
"goods": goods,
|
||
# Оплата «Експрес-накладна»: деньги примет НП при выдаче посылки,
|
||
# тогда Checkbox и фискализирует чек. Тип и подпись — явно, не полагаясь
|
||
# на значения по умолчанию Checkbox.
|
||
"payments": [
|
||
{
|
||
"type": ETTN_PAYMENT_TYPE,
|
||
"label": ETTN_PAYMENT_LABEL,
|
||
"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}
|