Each cash register stores its own encrypted NP API key. Status polling uses register keys and binds an order to the register whose key sees the TTN as its own (PhoneSender present); ETTN receipts are created from that register. Migration 0008 moves the old NOVA_POSHTA_API_KEY into the default register. Closes #3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
610 lines
26 KiB
Python
610 lines
26 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
|
||
|
||
import httpx
|
||
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,
|
||
CheckboxRateLimitedError,
|
||
CheckboxUnavailableError,
|
||
)
|
||
from app.services.crm.client import CrmClient, CrmError
|
||
|
||
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)
|
||
|
||
# Сколько `created`-чеков опрашивать за один прогон cron'а (раз в минуту):
|
||
# при паузе ~1 с между запросами к Checkbox прогон укладывается в минуту и
|
||
# не наслаивается на следующий.
|
||
_ETTN_POLL_BATCH = 30
|
||
|
||
# Статус заказа в CRM, когда Checkbox принял ЕТТН-чек: заказ можно собирать.
|
||
CRM_STATUS_AFTER_RECEIPT = "PACKED"
|
||
# Чеки, при которых заказу нужен этот статус в CRM (Checkbox чек принял).
|
||
_CRM_STATUS_RECEIPT_STATUSES = (
|
||
ReceiptStatus.CREATED,
|
||
ReceiptStatus.DONE,
|
||
ReceiptStatus.RECEIPT_ERROR,
|
||
ReceiptStatus.RETURNED,
|
||
)
|
||
|
||
|
||
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:
|
||
"""Телефон для отправки чека в формате `380XXXXXXXXX`, как в чеках из портала Checkbox."""
|
||
if not phone:
|
||
return None
|
||
digits = re.sub(r"\D", "", phone)
|
||
if len(digits) == 10 and digits.startswith("0"):
|
||
digits = "38" + digits
|
||
return 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)
|
||
|
||
# Скидка на весь заказ в CRM (не распределённая по строкам).
|
||
order_discount = goods_total - amounts.total_kopecks
|
||
if order_discount < 0:
|
||
raise ReceiptValidationError(
|
||
"Сумма товаров меньше суммы заказа — проверьте заказ в CRM"
|
||
)
|
||
# Предоплата и скидка заказа — одной обычной скидкой «Знижка», как в чеках из
|
||
# портала Checkbox. Тип `PRE_PAYMENT` для ЕТТН не годится: Checkbox отвечает
|
||
# 400 `third_party.generic` (проверено на боевой кассе).
|
||
discount_total = order_discount + amounts.prepayment_kopecks
|
||
discounts: list[dict[str, Any]] = []
|
||
if discount_total:
|
||
discounts.append(
|
||
{"type": "DISCOUNT", "mode": "VALUE", "value": discount_total, "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 _active_registers(session: AsyncSession) -> dict[uuid.UUID, CashRegister]:
|
||
registers = await session.scalars(
|
||
select(CashRegister).where(CashRegister.is_active.is_(True))
|
||
)
|
||
return {register.id: register for register in registers}
|
||
|
||
|
||
# --- Создание ----------------------------------------------------------------
|
||
|
||
|
||
@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`.
|
||
Ошибки по отдельным заказам не мешают остальным — массовое действие.
|
||
|
||
Чек создаётся от кассы, чей ключ НП видит ТТН заказа как свою
|
||
(`orders.cash_register_id`, проставляет `orders.sync_np_statuses`).
|
||
"""
|
||
result = RequestResult(created=[], errors={})
|
||
registers = await _active_registers(session)
|
||
if not registers:
|
||
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
|
||
register = registers.get(order.cash_register_id) if order.cash_register_id else None
|
||
if register is None:
|
||
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,
|
||
"cash_register_id": str(register.id),
|
||
"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. Идемпотентна — безопасно вызывать повторно.
|
||
|
||
На лимит частоты пробрасывает `CheckboxRateLimitedError`, чек остаётся `pending`.
|
||
"""
|
||
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 CheckboxRateLimitedError as exc:
|
||
# Запрос отклонён, чек точно не создан: остаётся `pending` без пометки
|
||
# «исход неизвестен». Повтор с задержкой — забота вызывающего (worker).
|
||
await session.commit() # снять блокировку строки
|
||
log.info("ettn_rate_limited", receipt_id=str(receipt_id), retry_after=exc.retry_after)
|
||
raise
|
||
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:
|
||
try:
|
||
await create_ettn_for_receipt(session, client, receipt_id)
|
||
except CheckboxRateLimitedError:
|
||
break # остальные — в следующем проходе cron'а
|
||
|
||
|
||
# --- Отмена и опрос ----------------------------------------------------------
|
||
|
||
|
||
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`).
|
||
|
||
За прогон — не больше `_ETTN_POLL_BATCH` давно не проверенных чеков, с
|
||
коммитом после каждого: запросы к Checkbox идут с паузой, и опрос всех
|
||
чеков разом не укладывался в таймаут cron-задачи, а откат по таймауту
|
||
терял весь прогон — статусы не обновлялись вовсе.
|
||
"""
|
||
receipts = list(
|
||
await session.scalars(
|
||
select(Receipt)
|
||
.where(Receipt.status == ReceiptStatus.CREATED)
|
||
.order_by(Receipt.last_checked_at.asc().nulls_first())
|
||
.limit(_ETTN_POLL_BATCH)
|
||
)
|
||
)
|
||
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 CheckboxUnavailableError as exc:
|
||
# Недоступен или лимит частоты запросов — остальные чеки опросим
|
||
# в следующий раз, а не будем добивать Checkbox прямо сейчас.
|
||
log.warning("ettn_poll_paused", receipt_id=str(receipt.id), error=str(exc))
|
||
break
|
||
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 sync_crm_statuses(session: AsyncSession, crm: CrmClient) -> None:
|
||
"""Переводит в CRM заказы с принятым Checkbox чеком в статус PACKED.
|
||
|
||
Отдельно от создания чека и идемпотентно по `crm_status_set_at`: сбой CRM
|
||
не должен ни откатывать уже созданный в Checkbox чек, ни теряться —
|
||
вызывается сразу после создания и повторяется cron'ом до успеха.
|
||
"""
|
||
receipt_ids = list(
|
||
await session.scalars(
|
||
select(Receipt.id)
|
||
.where(Receipt.status.in_(_CRM_STATUS_RECEIPT_STATUSES))
|
||
.where(Receipt.crm_status_set_at.is_(None))
|
||
)
|
||
)
|
||
for receipt_id in receipt_ids:
|
||
# Строка блокируется на время вызова CRM: задача создания и cron могут
|
||
# сработать одновременно, а повторный PACKED откатил бы статус, который
|
||
# менеджер уже успел сменить дальше. Занято или уже выставлено — пропуск.
|
||
receipt = await session.scalar(
|
||
select(Receipt)
|
||
.where(Receipt.id == receipt_id)
|
||
.where(Receipt.crm_status_set_at.is_(None))
|
||
.with_for_update(skip_locked=True)
|
||
)
|
||
if receipt is None:
|
||
continue
|
||
order_id = receipt.order_id # после rollback атрибуты истекают
|
||
try:
|
||
await crm.set_status(order_id=order_id, status=CRM_STATUS_AFTER_RECEIPT)
|
||
except (CrmError, httpx.HTTPError) as exc:
|
||
await session.rollback() # снять блокировку строки
|
||
log.warning("crm_status_set_failed", order_id=order_id, error=repr(exc))
|
||
continue
|
||
receipt.crm_status_set_at = datetime.now(UTC)
|
||
await audit.record(
|
||
session,
|
||
action=AuditAction.ORDER_CRM_STATUS_SET,
|
||
actor_label="worker",
|
||
entity_type="order",
|
||
entity_id=receipt.order_id,
|
||
payload={"status": CRM_STATUS_AFTER_RECEIPT, "receipt_id": str(receipt.id)},
|
||
)
|
||
# Коммит на каждый заказ: статус в CRM уже сменён, отметку нельзя терять
|
||
# из-за сбоя на следующем заказе.
|
||
await session.commit()
|
||
log.info("crm_status_set", order_id=receipt.order_id, status=CRM_STATUS_AFTER_RECEIPT)
|
||
|
||
|
||
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}
|