"""ЕТТН-чеки 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}