- All frontend pages, labels, notices and errors; html lang=uk, uk-UA money format - Brand "Assistant System" in the top bar and page title - Backend error details returned to the UI (auth, orders, receipts, cash registers, Checkbox/CRM/NP errors) and CLI output - Tests updated for the new messages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
610 lines
27 KiB
Python
610 lines
27 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}
|