Fix ETTN creation on live Checkbox
- prepayment as DISCOUNT: live API rejects PRE_PAYMENT with 400 third_party.generic - normalize lowercase ETTN statuses returned by the live API - show Checkbox error code instead of bare 'Internal Server Error' - page size 50 for ETTN list, 429 rate limit is retryable, pause polling on it - phone in 380XXXXXXXXX format like portal receipts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class EttnStatus:
|
||||
@@ -30,3 +30,10 @@ class EttnOut(BaseModel):
|
||||
total_sum: int | None = Field(default=None, alias="totalSum")
|
||||
receipt_id: str | None = Field(default=None, alias="receiptId")
|
||||
raw_error: str | None = Field(default=None, alias="rawError")
|
||||
|
||||
# В OpenAPI статусы заглавные, а боевой API отдаёт строчные (`"done"`,
|
||||
# `"created"`) — приводим к одному регистру, иначе статусы не сопоставятся.
|
||||
@field_validator("status", mode="before")
|
||||
@classmethod
|
||||
def _upper_status(cls, value: object) -> object:
|
||||
return value.upper() if isinstance(value, str) else value
|
||||
|
||||
@@ -23,8 +23,9 @@ from app.services.checkbox.client import (
|
||||
_PROVIDER = "novapost"
|
||||
_TIMEOUT = httpx.Timeout(30, connect=10)
|
||||
# Сколько страниц списка ЕТТН просматривать при сверке после таймаута.
|
||||
_FIND_PAGES = 5
|
||||
_FIND_PAGE_SIZE = 100
|
||||
# Список отдаётся от новых к старым; больше 50 за страницу Checkbox не принимает.
|
||||
_FIND_PAGES = 3
|
||||
_FIND_PAGE_SIZE = 50
|
||||
|
||||
|
||||
def _error_message(response: httpx.Response) -> str:
|
||||
@@ -35,6 +36,11 @@ def _error_message(response: httpx.Response) -> str:
|
||||
if isinstance(data, dict):
|
||||
message = data.get("message")
|
||||
detail = data.get("detail")
|
||||
# Ошибки со стороны Новой Почты Checkbox отдаёт как
|
||||
# {"code": "third_party.*", "message": "Internal Server Error"} —
|
||||
# без кода кассир видит только бесполезное «Internal Server Error».
|
||||
if code := data.get("code"):
|
||||
message = f"{message or 'Ошибка'} ({code})"
|
||||
if isinstance(detail, list) and detail:
|
||||
parts = [
|
||||
f"{'.'.join(str(p) for p in item.get('loc', []))}: {item.get('msg')}"
|
||||
@@ -74,7 +80,8 @@ class HttpCheckboxClient:
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise CheckboxUnavailableError(f"Checkbox недоступен: {exc!r}") from exc
|
||||
if response.status_code >= 500:
|
||||
# 429 — «Занадто часто виконуються запити»: исход не ошибка данных, повторяемо.
|
||||
if response.status_code >= 500 or response.status_code == 429:
|
||||
raise CheckboxUnavailableError(_error_message(response))
|
||||
return response
|
||||
|
||||
|
||||
@@ -88,13 +88,13 @@ def _line_sum(price_kopecks: int, quantity: int) -> int:
|
||||
|
||||
|
||||
def _normalize_phone(phone: str | None) -> str | None:
|
||||
"""Телефон для отправки чека: Checkbox принимает только `+?380\\d{9}`."""
|
||||
"""Телефон для отправки чека в формате `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 f"+{digits}" if re.fullmatch(r"380\d{9}", digits) else None
|
||||
return digits if re.fullmatch(r"380\d{9}", digits) else None
|
||||
|
||||
|
||||
# --- Тело запроса ------------------------------------------------------------
|
||||
@@ -145,7 +145,6 @@ 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
|
||||
@@ -153,18 +152,14 @@ def build_ettn_body(
|
||||
raise ReceiptValidationError(
|
||||
"Сумма товаров меньше суммы заказа — проверьте заказ в CRM"
|
||||
)
|
||||
if order_discount:
|
||||
# Предоплата и скидка заказа — одной обычной скидкой «Знижка», как в чеках из
|
||||
# портала 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": order_discount, "name": "Знижка"}
|
||||
)
|
||||
if amounts.prepayment_kopecks:
|
||||
discounts.append(
|
||||
{
|
||||
"type": "PRE_PAYMENT",
|
||||
"mode": "VALUE",
|
||||
"value": amounts.prepayment_kopecks,
|
||||
"name": "Передоплата",
|
||||
}
|
||||
{"type": "DISCOUNT", "mode": "VALUE", "value": discount_total, "name": "Знижка"}
|
||||
)
|
||||
|
||||
# Последняя страховка перед отправкой: сумма чека (товары − скидки − предоплата)
|
||||
@@ -490,6 +485,11 @@ async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> N
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user