Compare commits
2
Commits
56ac0fc370
...
528a869657
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
528a869657 | ||
|
|
f88b5cccc0 |
@@ -67,6 +67,8 @@ NOVA_POSHTA_API_KEY=change-me-novaposhta-apikey
|
|||||||
CHECKBOX_BASE_URL=https://api.checkbox.ua
|
CHECKBOX_BASE_URL=https://api.checkbox.ua
|
||||||
CHECKBOX_CLIENT_NAME=lux_fiscal
|
CHECKBOX_CLIENT_NAME=lux_fiscal
|
||||||
CHECKBOX_CLIENT_VERSION=0.1.0
|
CHECKBOX_CLIENT_VERSION=0.1.0
|
||||||
|
# Пауза между запросами к Checkbox (мс): Новая Почта за Checkbox ограничивает частоту.
|
||||||
|
CHECKBOX_MIN_REQUEST_INTERVAL_MS=1000
|
||||||
# ЕТТН-чеки на тестовой кассе Checkbox не работают: локально весь цикл
|
# ЕТТН-чеки на тестовой кассе Checkbox не работают: локально весь цикл
|
||||||
# прогоняется через стаб (чек «фискализируется» на втором опросе). В production запрещено.
|
# прогоняется через стаб (чек «фискализируется» на втором опросе). В production запрещено.
|
||||||
CHECKBOX_USE_STUB=false
|
CHECKBOX_USE_STUB=false
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ Not yet enforced by types anywhere in the current code, but is a hard project co
|
|||||||
|
|
||||||
## Project status (see the plan for the full roadmap)
|
## Project status (see the plan for the full roadmap)
|
||||||
|
|
||||||
Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) are done. Checkbox ETTN receipts are implemented per `.plans/checkbox-ettn-receipts.md` but not yet verified on a real cash register.
|
Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) are done. The dashboard has three tabs (`GET /orders?tab=no_receipt|has_receipt|refused`, `services/orders.OrderTab`); an order whose NP status code is a refusal (`NP_REFUSAL_STATUS_CODES`) shows only under «Отказы», whether or not it has a receipt. Checkbox ETTN receipts are implemented per `.plans/checkbox-ettn-receipts.md` but not yet verified on a real cash register.
|
||||||
|
|
||||||
### Checkbox ETTN receipts
|
### Checkbox ETTN receipts
|
||||||
|
|
||||||
@@ -126,4 +126,5 @@ Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) ar
|
|||||||
- Two-phase create: `POST /receipts` writes `Receipt(pending)` + audit and commits, then enqueues `create_ettn_receipt`; the worker calls Checkbox. A timeout leaves the row `pending` with `error` set — the retry first looks the TTN up via `find_ettn` instead of blindly re-posting (would create a second receipt). Keep this.
|
- Two-phase create: `POST /receipts` writes `Receipt(pending)` + audit and commits, then enqueues `create_ettn_receipt`; the worker calls Checkbox. A timeout leaves the row `pending` with `error` set — the retry first looks the TTN up via `find_ettn` instead of blindly re-posting (would create a second receipt). Keep this.
|
||||||
- State machine and the "one live receipt per order" partial unique index live in `app/db/models/receipt.py`. `orders.receipt_created_at` is set on request and reset to NULL when a receipt ends `failed`/`cancelled` (order goes back to the queue).
|
- State machine and the "one live receipt per order" partial unique index live in `app/db/models/receipt.py`. `orders.receipt_created_at` is set on request and reset to NULL when a receipt ends `failed`/`cancelled` (order goes back to the queue).
|
||||||
- Once Checkbox accepts the receipt, the order is moved to `PACKED` in the CRM (`services/receipts.sync_crm_statuses`, marked by `receipts.crm_status_set_at`; runs right after creation and is retried by cron). The live exoCRM `SetStatus` differs from its docs: params must be `{"Orders": [id], "Status": ...}` (the documented `{"ID": ...}` returns "Undefined order list."), and the reply has no `status: OK` — success is `{"<id>": {"Status": "Success"}}`.
|
- Once Checkbox accepts the receipt, the order is moved to `PACKED` in the CRM (`services/receipts.sync_crm_statuses`, marked by `receipts.crm_status_set_at`; runs right after creation and is retried by cron). The live exoCRM `SetStatus` differs from its docs: params must be `{"Orders": [id], "Status": ...}` (the documented `{"ID": ...}` returns "Undefined order list."), and the reply has no `status: OK` — success is `{"<id>": {"Status": "Success"}}`.
|
||||||
|
- Nova Poshta's rate limit comes back through Checkbox as a 4xx with `code=third_party.generic` and «To many requests» / `20000401501`, not as a 429. `http_client._transient_error` maps it to `CheckboxRateLimitedError`: the receipt stays `pending` and the worker retries with `arq.Retry`. The client also sends requests one at a time with a `CHECKBOX_MIN_REQUEST_INTERVAL_MS` pause, so don't parallelize Checkbox calls in the worker.
|
||||||
- ETTN does **not** work on a Checkbox test cash register. Locally use `CHECKBOX_USE_STUB=true`; client selection is only in `services/checkbox/client.get_checkbox_client()`.
|
- ETTN does **not** work on a Checkbox test cash register. Locally use `CHECKBOX_USE_STUB=true`; client selection is only in `services/checkbox/client.get_checkbox_client()`.
|
||||||
|
|||||||
@@ -10,18 +10,19 @@ from app.schemas.orders import OrderRowOut, OrderUpdateIn
|
|||||||
from app.services import audit
|
from app.services import audit
|
||||||
from app.services import orders as orders_service
|
from app.services import orders as orders_service
|
||||||
from app.services import receipts as receipts_service
|
from app.services import receipts as receipts_service
|
||||||
|
from app.services.orders import OrderTab
|
||||||
|
|
||||||
router = APIRouter(prefix="/orders", tags=["orders"], dependencies=[Depends(require_any)])
|
router = APIRouter(prefix="/orders", tags=["orders"], dependencies=[Depends(require_any)])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[OrderRowOut], response_model_by_alias=False)
|
@router.get("", response_model=list[OrderRowOut], response_model_by_alias=False)
|
||||||
async def list_orders(
|
async def list_orders(
|
||||||
session: SessionDep, crm: CrmClientDep, has_receipt: bool = False
|
session: SessionDep, crm: CrmClientDep, tab: OrderTab = OrderTab.NO_RECEIPT
|
||||||
) -> list[OrderRowOut]:
|
) -> list[OrderRowOut]:
|
||||||
# by_alias=False: без этого FastAPI сериализует вложенные OrderGoodOut через
|
# by_alias=False: без этого FastAPI сериализует вложенные OrderGoodOut через
|
||||||
# CRM-алиасы (SKU/Name/...) вместо snake_case полей, которые ждёт фронтенд.
|
# CRM-алиасы (SKU/Name/...) вместо snake_case полей, которые ждёт фронтенд.
|
||||||
await orders_service.sync_orders_from_crm(session, crm)
|
await orders_service.sync_orders_from_crm(session, crm)
|
||||||
orders = await orders_service.list_orders(session, has_receipt=has_receipt)
|
orders = await orders_service.list_orders(session, tab=tab)
|
||||||
receipts = await receipts_service.latest_receipts_by_order(
|
receipts = await receipts_service.latest_receipts_by_order(
|
||||||
session, [order.id for order in orders]
|
session, [order.id for order in orders]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ class Settings(BaseSettings):
|
|||||||
checkbox_base_url: str = "https://api.checkbox.ua"
|
checkbox_base_url: str = "https://api.checkbox.ua"
|
||||||
checkbox_client_name: str = "lux_fiscal"
|
checkbox_client_name: str = "lux_fiscal"
|
||||||
checkbox_client_version: str = "0.1.0"
|
checkbox_client_version: str = "0.1.0"
|
||||||
|
# Пауза между запросами к Checkbox из одного процесса: при создании ЕТТН
|
||||||
|
# Checkbox ходит в API Новой Почты, а та отвечает «To many requests» на частые вызовы.
|
||||||
|
checkbox_min_request_interval_ms: int = 1000
|
||||||
# Стаб вместо реального Checkbox: ЕТТН работает только на боевой кассе,
|
# Стаб вместо реального Checkbox: ЕТТН работает только на боевой кассе,
|
||||||
# поэтому локально весь цикл прогоняется через стаб. В проде запрещено.
|
# поэтому локально весь цикл прогоняется через стаб. В проде запрещено.
|
||||||
checkbox_use_stub: bool = False
|
checkbox_use_stub: bool = False
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ class CheckboxUnavailableError(CheckboxError):
|
|||||||
"""Сеть, таймаут или 5xx — исход запроса неизвестен, можно повторить."""
|
"""Сеть, таймаут или 5xx — исход запроса неизвестен, можно повторить."""
|
||||||
|
|
||||||
|
|
||||||
|
class CheckboxRateLimitedError(CheckboxUnavailableError):
|
||||||
|
"""Лимит частоты запросов (Checkbox 429 или «To many requests» от Новой Почты).
|
||||||
|
|
||||||
|
В отличие от прочих `CheckboxUnavailableError` исход известен — запрос отклонён,
|
||||||
|
повторять можно не раньше чем через `retry_after` секунд.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, retry_after: float) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.retry_after = retry_after
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CheckboxCredentials:
|
class CheckboxCredentials:
|
||||||
"""Расшифрованные доступы одной кассы. Живут только в памяти."""
|
"""Расшифрованные доступы одной кассы. Живут только в памяти."""
|
||||||
|
|||||||
@@ -2,10 +2,17 @@
|
|||||||
|
|
||||||
Авторизация — токен кассира по PIN-коду (`/api/v1/cashier/signinPinCode`).
|
Авторизация — токен кассира по PIN-коду (`/api/v1/cashier/signinPinCode`).
|
||||||
Токен кэшируется в памяти процесса по кассе; на 401 — один повторный вход.
|
Токен кэшируется в памяти процесса по кассе; на 401 — один повторный вход.
|
||||||
|
|
||||||
|
Запросы из одного процесса идут строго по одному с паузой
|
||||||
|
`CHECKBOX_MIN_REQUEST_INTERVAL_MS`: при создании ЕТТН Checkbox синхронно ходит
|
||||||
|
в API Новой Почты, а та на частые вызовы отвечает «To many requests».
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -17,6 +24,7 @@ from app.schemas.checkbox import EttnOut, EttnStatus
|
|||||||
from app.services.checkbox.client import (
|
from app.services.checkbox.client import (
|
||||||
CheckboxCredentials,
|
CheckboxCredentials,
|
||||||
CheckboxError,
|
CheckboxError,
|
||||||
|
CheckboxRateLimitedError,
|
||||||
CheckboxUnavailableError,
|
CheckboxUnavailableError,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,6 +35,15 @@ _TIMEOUT = httpx.Timeout(30, connect=10)
|
|||||||
_FIND_PAGES = 3
|
_FIND_PAGES = 3
|
||||||
_FIND_PAGE_SIZE = 50
|
_FIND_PAGE_SIZE = 50
|
||||||
|
|
||||||
|
# Ошибки Новой Почты, которые Checkbox отдаёт 4xx с `code=third_party.*`:
|
||||||
|
# {"message": "To many requests", "info": ["Try again after 1 seconds"],
|
||||||
|
# "errorCodes": ["20000401501"]} — лимит частоты НП;
|
||||||
|
# "cURL error 28: SSL connection timeout" — Checkbox не дождался НП.
|
||||||
|
_NP_RATE_LIMIT_RE = re.compile(r"20000401501|too? many requests", re.IGNORECASE)
|
||||||
|
_NP_RETRY_AFTER_RE = re.compile(r"try again after (\d+) second", re.IGNORECASE)
|
||||||
|
_NP_TIMEOUT_RE = re.compile(r"curl error|timed? ?out", re.IGNORECASE)
|
||||||
|
_MIN_RETRY_AFTER = 1.0
|
||||||
|
|
||||||
|
|
||||||
def _error_message(response: httpx.Response) -> str:
|
def _error_message(response: httpx.Response) -> str:
|
||||||
try:
|
try:
|
||||||
@@ -55,6 +72,29 @@ def _error_message(response: httpx.Response) -> str:
|
|||||||
return f"HTTP {response.status_code}"
|
return f"HTTP {response.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_after(response: httpx.Response) -> float:
|
||||||
|
seconds: float = 0
|
||||||
|
if match := _NP_RETRY_AFTER_RE.search(response.text):
|
||||||
|
seconds = float(match.group(1))
|
||||||
|
elif (header := response.headers.get("Retry-After", "")).isdigit():
|
||||||
|
seconds = float(header)
|
||||||
|
return max(seconds, _MIN_RETRY_AFTER)
|
||||||
|
|
||||||
|
|
||||||
|
def _transient_error(response: httpx.Response) -> CheckboxUnavailableError | None:
|
||||||
|
"""Ответ, после которого запрос можно повторить, или None, если ошибка окончательная."""
|
||||||
|
if response.status_code == 429:
|
||||||
|
return CheckboxRateLimitedError(_error_message(response), _retry_after(response))
|
||||||
|
if response.status_code >= 500:
|
||||||
|
return CheckboxUnavailableError(_error_message(response))
|
||||||
|
if response.status_code >= 400 and '"third_party.' in response.text:
|
||||||
|
if _NP_RATE_LIMIT_RE.search(response.text):
|
||||||
|
return CheckboxRateLimitedError(_error_message(response), _retry_after(response))
|
||||||
|
if _NP_TIMEOUT_RE.search(response.text):
|
||||||
|
return CheckboxUnavailableError(_error_message(response))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class HttpCheckboxClient:
|
class HttpCheckboxClient:
|
||||||
def __init__(self, settings: Settings) -> None:
|
def __init__(self, settings: Settings) -> None:
|
||||||
self._base_url = settings.checkbox_base_url.rstrip("/")
|
self._base_url = settings.checkbox_base_url.rstrip("/")
|
||||||
@@ -63,6 +103,10 @@ class HttpCheckboxClient:
|
|||||||
"X-Client-Version": settings.checkbox_client_version,
|
"X-Client-Version": settings.checkbox_client_version,
|
||||||
}
|
}
|
||||||
self._tokens: dict[uuid.UUID, str] = {}
|
self._tokens: dict[uuid.UUID, str] = {}
|
||||||
|
self._min_interval = settings.checkbox_min_request_interval_ms / 1000
|
||||||
|
self._send_lock = asyncio.Lock()
|
||||||
|
self._sign_in_lock = asyncio.Lock()
|
||||||
|
self._last_sent_at = float("-inf")
|
||||||
|
|
||||||
async def _send(
|
async def _send(
|
||||||
self,
|
self,
|
||||||
@@ -73,18 +117,36 @@ class HttpCheckboxClient:
|
|||||||
json: Any = None,
|
json: Any = None,
|
||||||
params: dict[str, Any] | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
) -> httpx.Response:
|
) -> httpx.Response:
|
||||||
try:
|
# Строго по одному запросу с паузой — параллельные задачи worker'а
|
||||||
async with httpx.AsyncClient(base_url=self._base_url, timeout=_TIMEOUT) as client:
|
# иначе пачкой упираются в лимит Новой Почты.
|
||||||
response = await client.request(
|
async with self._send_lock:
|
||||||
method, path, headers=headers, json=json, params=params
|
delay = self._last_sent_at + self._min_interval - time.monotonic()
|
||||||
)
|
if delay > 0:
|
||||||
except httpx.HTTPError as exc:
|
await asyncio.sleep(delay)
|
||||||
raise CheckboxUnavailableError(f"Checkbox недоступен: {exc!r}") from exc
|
self._last_sent_at = time.monotonic()
|
||||||
# 429 — «Занадто часто виконуються запити»: исход не ошибка данных, повторяемо.
|
try:
|
||||||
if response.status_code >= 500 or response.status_code == 429:
|
async with httpx.AsyncClient(base_url=self._base_url, timeout=_TIMEOUT) as client:
|
||||||
raise CheckboxUnavailableError(_error_message(response))
|
response = await client.request(
|
||||||
|
method, path, headers=headers, json=json, params=params
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise CheckboxUnavailableError(f"Checkbox недоступен: {exc!r}") from exc
|
||||||
|
if error := _transient_error(response):
|
||||||
|
raise error
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
async def _token(self, creds: CheckboxCredentials, *, stale: str | None = None) -> str:
|
||||||
|
"""Токен кассира; `stale` — отвергнутый Checkbox'ом (401), его не переиспользуем.
|
||||||
|
|
||||||
|
Под блокировкой: параллельные задачи без токена входят один раз,
|
||||||
|
остальные берут токен, полученный первой.
|
||||||
|
"""
|
||||||
|
async with self._sign_in_lock:
|
||||||
|
token = self._tokens.get(creds.cash_register_id)
|
||||||
|
if token is not None and token != stale:
|
||||||
|
return token
|
||||||
|
return await self._sign_in(creds)
|
||||||
|
|
||||||
async def _sign_in(self, creds: CheckboxCredentials) -> str:
|
async def _sign_in(self, creds: CheckboxCredentials) -> str:
|
||||||
response = await self._send(
|
response = await self._send(
|
||||||
"POST",
|
"POST",
|
||||||
@@ -107,7 +169,7 @@ class HttpCheckboxClient:
|
|||||||
json: Any = None,
|
json: Any = None,
|
||||||
params: dict[str, Any] | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
) -> httpx.Response:
|
) -> httpx.Response:
|
||||||
token = self._tokens.get(creds.cash_register_id) or await self._sign_in(creds)
|
token = await self._token(creds)
|
||||||
for attempt in range(2):
|
for attempt in range(2):
|
||||||
response = await self._send(
|
response = await self._send(
|
||||||
method,
|
method,
|
||||||
@@ -121,7 +183,7 @@ class HttpCheckboxClient:
|
|||||||
params=params,
|
params=params,
|
||||||
)
|
)
|
||||||
if response.status_code == 401 and attempt == 0:
|
if response.status_code == 401 and attempt == 0:
|
||||||
token = await self._sign_in(creds)
|
token = await self._token(creds, stale=token)
|
||||||
continue
|
continue
|
||||||
break
|
break
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from decimal import ROUND_HALF_UP, Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
@@ -20,6 +21,14 @@ _CRM_STATUS = "APPROVED"
|
|||||||
# NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py).
|
# NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py).
|
||||||
_NP_BATCH_SIZE = 100
|
_NP_BATCH_SIZE = 100
|
||||||
|
|
||||||
|
# Коды NP "відмова одержувача" / "припинено зберігання" — клиент не забрал
|
||||||
|
# посылку, заказ уходит во вкладку «Отказы».
|
||||||
|
NP_REFUSAL_STATUS_CODES = ("102", "103", "105", "108")
|
||||||
|
|
||||||
|
# Коды NP "відправлення отримано" и отказы — после них статус ТТН больше не
|
||||||
|
# опрашивается.
|
||||||
|
_NP_FINAL_STATUS_CODES = ("9", "10", "11", *NP_REFUSAL_STATUS_CODES)
|
||||||
|
|
||||||
# Поля, которые кассир может править в карточке заказа (кроме goods/total).
|
# Поля, которые кассир может править в карточке заказа (кроме goods/total).
|
||||||
_EDITABLE_FIELDS = (
|
_EDITABLE_FIELDS = (
|
||||||
"recipient_name",
|
"recipient_name",
|
||||||
@@ -30,6 +39,12 @@ _EDITABLE_FIELDS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderTab(enum.StrEnum):
|
||||||
|
NO_RECEIPT = "no_receipt"
|
||||||
|
HAS_RECEIPT = "has_receipt"
|
||||||
|
REFUSED = "refused"
|
||||||
|
|
||||||
|
|
||||||
class OrderEditError(Exception):
|
class OrderEditError(Exception):
|
||||||
"""Заказ нельзя сохранить — сообщение показывается кассиру."""
|
"""Заказ нельзя сохранить — сообщение показывается кассиру."""
|
||||||
|
|
||||||
@@ -80,21 +95,31 @@ async def sync_orders_from_crm(session: AsyncSession, crm: CrmClient) -> None:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def list_orders(session: AsyncSession, *, has_receipt: bool) -> list[Order]:
|
async def list_orders(session: AsyncSession, *, tab: OrderTab) -> list[Order]:
|
||||||
receipt_filter = (
|
"""Заказы вкладки. Отказ клиента важнее наличия чека: такой заказ показывается
|
||||||
Order.receipt_created_at.is_not(None) if has_receipt else Order.receipt_created_at.is_(None)
|
только в «Отказах», даже если по нему уже создан ЕТТН-чек."""
|
||||||
)
|
refused = Order.np_status_code.in_(NP_REFUSAL_STATUS_CODES)
|
||||||
|
not_refused = Order.np_status_code.is_(None) | ~refused
|
||||||
|
tab_filter = {
|
||||||
|
OrderTab.REFUSED: refused,
|
||||||
|
OrderTab.NO_RECEIPT: Order.receipt_created_at.is_(None) & not_refused,
|
||||||
|
OrderTab.HAS_RECEIPT: Order.receipt_created_at.is_not(None) & not_refused,
|
||||||
|
}[tab]
|
||||||
result = await session.scalars(
|
result = await session.scalars(
|
||||||
select(Order)
|
select(Order)
|
||||||
.where(Order.is_deleted.is_(False))
|
.where(Order.is_deleted.is_(False))
|
||||||
.where(receipt_filter)
|
.where(tab_filter)
|
||||||
.order_by(Order.create_date_time.desc())
|
.order_by(Order.create_date_time.desc())
|
||||||
)
|
)
|
||||||
return list(result)
|
return list(result)
|
||||||
|
|
||||||
|
|
||||||
async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
|
async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
|
||||||
"""Обновляет статус ТТН и сумму наложенного платежа для заказов без чека.
|
"""Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути.
|
||||||
|
|
||||||
|
Опрашиваются и заказы с уже созданным ЕТТН-чеком: именно после создания
|
||||||
|
чека посылка доезжает и оплачивается, и этот переход кассир должен видеть.
|
||||||
|
Опрос прекращается, когда NP вернул финальный статус "отримано" или отказ.
|
||||||
|
|
||||||
Вызывается ARQ worker'ом раз в минуту (см. `app/worker.py`), а не из
|
Вызывается ARQ worker'ом раз в минуту (см. `app/worker.py`), а не из
|
||||||
HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас
|
HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас
|
||||||
@@ -103,8 +128,11 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
|
|||||||
orders = await session.scalars(
|
orders = await session.scalars(
|
||||||
select(Order)
|
select(Order)
|
||||||
.where(Order.is_deleted.is_(False))
|
.where(Order.is_deleted.is_(False))
|
||||||
.where(Order.receipt_created_at.is_(None))
|
|
||||||
.where(Order.waybill_number.is_not(None))
|
.where(Order.waybill_number.is_not(None))
|
||||||
|
.where(
|
||||||
|
Order.np_status_code.is_(None)
|
||||||
|
| Order.np_status_code.not_in(_NP_FINAL_STATUS_CODES)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
orders_by_waybill: dict[str, Order] = {order.waybill_number: order for order in orders}
|
orders_by_waybill: dict[str, Order] = {order.waybill_number: order for order in orders}
|
||||||
if not orders_by_waybill:
|
if not orders_by_waybill:
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from app.services.checkbox.client import (
|
|||||||
CheckboxClient,
|
CheckboxClient,
|
||||||
CheckboxCredentials,
|
CheckboxCredentials,
|
||||||
CheckboxError,
|
CheckboxError,
|
||||||
|
CheckboxRateLimitedError,
|
||||||
CheckboxUnavailableError,
|
CheckboxUnavailableError,
|
||||||
)
|
)
|
||||||
from app.services.crm.client import CrmClient, CrmError
|
from app.services.crm.client import CrmClient, CrmError
|
||||||
@@ -387,7 +388,10 @@ def _apply_ettn(receipt: Receipt, ettn: EttnOut) -> None:
|
|||||||
async def create_ettn_for_receipt(
|
async def create_ettn_for_receipt(
|
||||||
session: AsyncSession, client: CheckboxClient, receipt_id: uuid.UUID
|
session: AsyncSession, client: CheckboxClient, receipt_id: uuid.UUID
|
||||||
) -> Receipt | None:
|
) -> Receipt | None:
|
||||||
"""Отправляет `pending`-чек в Checkbox. Идемпотентна — безопасно вызывать повторно."""
|
"""Отправляет `pending`-чек в Checkbox. Идемпотентна — безопасно вызывать повторно.
|
||||||
|
|
||||||
|
На лимит частоты пробрасывает `CheckboxRateLimitedError`, чек остаётся `pending`.
|
||||||
|
"""
|
||||||
receipt = await session.get(Receipt, receipt_id, with_for_update=True)
|
receipt = await session.get(Receipt, receipt_id, with_for_update=True)
|
||||||
if receipt is None or receipt.status != ReceiptStatus.PENDING:
|
if receipt is None or receipt.status != ReceiptStatus.PENDING:
|
||||||
return receipt
|
return receipt
|
||||||
@@ -403,6 +407,12 @@ async def create_ettn_for_receipt(
|
|||||||
await client.find_ettn(creds, receipt.waybill_number) if receipt.error else None
|
await client.find_ettn(creds, receipt.waybill_number) if receipt.error else None
|
||||||
)
|
)
|
||||||
ettn = existing or await client.create_ettn(creds, receipt.request_body)
|
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:
|
except CheckboxUnavailableError as exc:
|
||||||
receipt.error = str(exc)
|
receipt.error = str(exc)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -435,7 +445,10 @@ async def retry_pending_receipts(session: AsyncSession, client: CheckboxClient)
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
for receipt_id in receipt_ids:
|
for receipt_id in receipt_ids:
|
||||||
await create_ettn_for_receipt(session, client, receipt_id)
|
try:
|
||||||
|
await create_ettn_for_receipt(session, client, receipt_id)
|
||||||
|
except CheckboxRateLimitedError:
|
||||||
|
break # остальные — в следующем проходе cron'а
|
||||||
|
|
||||||
|
|
||||||
# --- Отмена и опрос ----------------------------------------------------------
|
# --- Отмена и опрос ----------------------------------------------------------
|
||||||
|
|||||||
+15
-6
@@ -12,20 +12,24 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from arq import cron
|
from arq import Retry, cron, func
|
||||||
from arq.connections import RedisSettings
|
from arq.connections import RedisSettings
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.logging import configure_logging, get_logger
|
from app.core.logging import configure_logging, get_logger
|
||||||
from app.db.session import SessionFactory
|
from app.db.session import SessionFactory
|
||||||
from app.services import receipts as receipts_service
|
from app.services import receipts as receipts_service
|
||||||
from app.services.checkbox.client import get_checkbox_client
|
from app.services.checkbox.client import CheckboxRateLimitedError, get_checkbox_client
|
||||||
from app.services.crm.exo_client import ExoCrmClient
|
from app.services.crm.exo_client import ExoCrmClient
|
||||||
from app.services.nova_poshta.np_client import NpTrackingClient
|
from app.services.nova_poshta.np_client import NpTrackingClient
|
||||||
from app.services.orders import sync_np_statuses
|
from app.services.orders import sync_np_statuses
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
# Повторы `create_ettn_receipt` при лимите частоты: пауза retry_after + 5 с × номер попытки.
|
||||||
|
_RATE_LIMIT_BACKOFF = 5
|
||||||
|
_CREATE_ETTN_MAX_TRIES = 6
|
||||||
|
|
||||||
|
|
||||||
async def startup(ctx: dict[str, Any]) -> None:
|
async def startup(ctx: dict[str, Any]) -> None:
|
||||||
configure_logging()
|
configure_logging()
|
||||||
@@ -43,9 +47,14 @@ async def poll_np_statuses(ctx: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
async def create_ettn_receipt(ctx: dict[str, Any], receipt_id: str) -> None:
|
async def create_ettn_receipt(ctx: dict[str, Any], receipt_id: str) -> None:
|
||||||
async with SessionFactory() as session:
|
async with SessionFactory() as session:
|
||||||
await receipts_service.create_ettn_for_receipt(
|
try:
|
||||||
session, ctx["checkbox_client"], uuid.UUID(receipt_id)
|
await receipts_service.create_ettn_for_receipt(
|
||||||
)
|
session, ctx["checkbox_client"], uuid.UUID(receipt_id)
|
||||||
|
)
|
||||||
|
except CheckboxRateLimitedError as exc:
|
||||||
|
# Лимит частоты Новой Почты: повтор с нарастающей паузой. Когда попытки
|
||||||
|
# кончатся, `pending`-чек подберёт cron `poll_receipts`.
|
||||||
|
raise Retry(defer=exc.retry_after + _RATE_LIMIT_BACKOFF * ctx["job_try"]) from exc
|
||||||
# Чек принят Checkbox — сразу переводим заказ в CRM в PACKED.
|
# Чек принят Checkbox — сразу переводим заказ в CRM в PACKED.
|
||||||
await receipts_service.sync_crm_statuses(session, ctx["crm_client"])
|
await receipts_service.sync_crm_statuses(session, ctx["crm_client"])
|
||||||
|
|
||||||
@@ -61,7 +70,7 @@ async def poll_receipts(ctx: dict[str, Any]) -> None:
|
|||||||
class WorkerSettings:
|
class WorkerSettings:
|
||||||
redis_settings = RedisSettings.from_dsn(settings.redis_url)
|
redis_settings = RedisSettings.from_dsn(settings.redis_url)
|
||||||
on_startup = startup
|
on_startup = startup
|
||||||
functions = [create_ettn_receipt]
|
functions = [func(create_ettn_receipt, max_tries=_CREATE_ETTN_MAX_TRIES)]
|
||||||
cron_jobs = [
|
cron_jobs = [
|
||||||
cron(poll_np_statuses, minute=set(range(60)), run_at_startup=True),
|
cron(poll_np_statuses, minute=set(range(60)), run_at_startup=True),
|
||||||
cron(poll_receipts, minute=set(range(60)), second=30),
|
cron(poll_receipts, minute=set(range(60)), second=30),
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -14,6 +16,7 @@ from app.core.config import Settings
|
|||||||
from app.services.checkbox.client import (
|
from app.services.checkbox.client import (
|
||||||
CheckboxCredentials,
|
CheckboxCredentials,
|
||||||
CheckboxError,
|
CheckboxError,
|
||||||
|
CheckboxRateLimitedError,
|
||||||
CheckboxUnavailableError,
|
CheckboxUnavailableError,
|
||||||
)
|
)
|
||||||
from app.services.checkbox.http_client import HttpCheckboxClient
|
from app.services.checkbox.http_client import HttpCheckboxClient
|
||||||
@@ -35,11 +38,12 @@ ETTN = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _client() -> HttpCheckboxClient:
|
def _client(interval_ms: int = 0) -> HttpCheckboxClient:
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
secret_key="test-secret-key",
|
secret_key="test-secret-key",
|
||||||
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
|
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
|
||||||
checkbox_base_url=BASE,
|
checkbox_base_url=BASE,
|
||||||
|
checkbox_min_request_interval_ms=interval_ms,
|
||||||
) # type: ignore[arg-type]
|
) # type: ignore[arg-type]
|
||||||
return HttpCheckboxClient(settings)
|
return HttpCheckboxClient(settings)
|
||||||
|
|
||||||
@@ -178,5 +182,78 @@ async def test_rate_limit_is_retryable() -> None:
|
|||||||
respx.post(f"{BASE}/api/v1/ettn").mock(
|
respx.post(f"{BASE}/api/v1/ettn").mock(
|
||||||
return_value=Response(429, json={"message": "Занадто часто виконуються запити"})
|
return_value=Response(429, json={"message": "Занадто часто виконуються запити"})
|
||||||
)
|
)
|
||||||
with pytest.raises(CheckboxUnavailableError):
|
with pytest.raises(CheckboxRateLimitedError):
|
||||||
await _client().create_ettn(CREDS, {})
|
await _client().create_ettn(CREDS, {})
|
||||||
|
|
||||||
|
|
||||||
|
def _np_error(message: str, info: list[str], codes: list[str]) -> Response:
|
||||||
|
"""Ошибка Новой Почты в том виде, в каком её отдаёт боевой Checkbox."""
|
||||||
|
inner = {
|
||||||
|
"type": "ettn",
|
||||||
|
"message": message,
|
||||||
|
"code": 0,
|
||||||
|
"context": {"success": False, "errors": [message], "info": info, "errorCodes": codes},
|
||||||
|
}
|
||||||
|
return Response(400, json={"code": "third_party.generic", "message": json.dumps(inner)})
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_np_too_many_requests_is_rate_limited() -> None:
|
||||||
|
_signin()
|
||||||
|
respx.post(f"{BASE}/api/v1/ettn").mock(
|
||||||
|
return_value=_np_error("To many requests", ["Try again after 3 seconds"], ["20000401501"])
|
||||||
|
)
|
||||||
|
with pytest.raises(CheckboxRateLimitedError) as exc_info:
|
||||||
|
await _client().create_ettn(CREDS, {})
|
||||||
|
assert exc_info.value.retry_after == 3
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_np_zero_retry_after_waits_at_least_a_second() -> None:
|
||||||
|
_signin()
|
||||||
|
respx.post(f"{BASE}/api/v1/ettn").mock(
|
||||||
|
return_value=_np_error("To many requests", ["Try again after 0 seconds"], ["20000401501"])
|
||||||
|
)
|
||||||
|
with pytest.raises(CheckboxRateLimitedError) as exc_info:
|
||||||
|
await _client().create_ettn(CREDS, {})
|
||||||
|
assert exc_info.value.retry_after == 1
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_np_timeout_is_unavailable_not_rate_limited() -> None:
|
||||||
|
_signin()
|
||||||
|
respx.post(f"{BASE}/api/v1/ettn").mock(
|
||||||
|
return_value=_np_error("cURL error 28: SSL connection timeout", [], [])
|
||||||
|
)
|
||||||
|
with pytest.raises(CheckboxUnavailableError) as exc_info:
|
||||||
|
await _client().create_ettn(CREDS, {})
|
||||||
|
assert not isinstance(exc_info.value, CheckboxRateLimitedError)
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_concurrent_calls_sign_in_once() -> None:
|
||||||
|
signin = _signin()
|
||||||
|
respx.get(f"{BASE}/api/v1/ettn/e-1").mock(return_value=Response(200, json=ETTN))
|
||||||
|
client = _client()
|
||||||
|
|
||||||
|
await asyncio.gather(*(client.get_ettn(CREDS, "e-1") for _ in range(5)))
|
||||||
|
|
||||||
|
assert signin.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_requests_are_spaced_by_min_interval() -> None:
|
||||||
|
_signin()
|
||||||
|
sent_at: list[float] = []
|
||||||
|
|
||||||
|
def record(_: httpx.Request) -> Response:
|
||||||
|
sent_at.append(time.monotonic())
|
||||||
|
return Response(200, json=ETTN)
|
||||||
|
|
||||||
|
respx.get(f"{BASE}/api/v1/ettn/e-1").mock(side_effect=record)
|
||||||
|
client = _client(interval_ms=50)
|
||||||
|
|
||||||
|
await asyncio.gather(*(client.get_ettn(CREDS, "e-1") for _ in range(3)))
|
||||||
|
|
||||||
|
gaps = [b - a for a, b in zip(sent_at, sent_at[1:], strict=False)]
|
||||||
|
assert len(gaps) == 2 and all(gap >= 0.045 for gap in gaps)
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
async def fake_sync(session: object, crm: object) -> None:
|
async def fake_sync(session: object, crm: object) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def fake_list(session: object, *, has_receipt: bool) -> list[Order]:
|
async def fake_list(session: object, *, tab: str) -> list[Order]:
|
||||||
return [] if has_receipt else [_order()]
|
return [_order()] if tab == "no_receipt" else []
|
||||||
|
|
||||||
async def fake_delete(session: object, order_id: str) -> Order | None:
|
async def fake_delete(session: object, order_id: str) -> Order | None:
|
||||||
return _order(order_id) if order_id == "1" else None
|
return _order(order_id) if order_id == "1" else None
|
||||||
@@ -132,7 +132,7 @@ class TestListOrders:
|
|||||||
) -> None:
|
) -> None:
|
||||||
app.dependency_overrides[get_current_user] = lambda: _user(role)
|
app.dependency_overrides[get_current_user] = lambda: _user(role)
|
||||||
|
|
||||||
response = client.get("/api/v1/orders", params={"has_receipt": "false"})
|
response = client.get("/api/v1/orders", params={"tab": "no_receipt"})
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
body = response.json()
|
body = response.json()
|
||||||
@@ -142,14 +142,22 @@ class TestListOrders:
|
|||||||
assert body[0]["has_receipt"] is False
|
assert body[0]["has_receipt"] is False
|
||||||
assert body[0]["goods"][0]["sku"] == "SKU-1"
|
assert body[0]["goods"][0]["sku"] == "SKU-1"
|
||||||
|
|
||||||
def test_has_receipt_tab_is_empty_for_now(self, client: TestClient) -> None:
|
@pytest.mark.parametrize("tab", ["has_receipt", "refused"])
|
||||||
|
def test_other_tabs_are_passed_to_service(self, client: TestClient, tab: str) -> None:
|
||||||
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
|
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
|
||||||
|
|
||||||
response = client.get("/api/v1/orders", params={"has_receipt": "true"})
|
response = client.get("/api/v1/orders", params={"tab": tab})
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == []
|
assert response.json() == []
|
||||||
|
|
||||||
|
def test_unknown_tab_is_rejected(self, client: TestClient) -> None:
|
||||||
|
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
|
||||||
|
|
||||||
|
response = client.get("/api/v1/orders", params={"tab": "bogus"})
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_requires_authentication(self, client: TestClient) -> None:
|
def test_requires_authentication(self, client: TestClient) -> None:
|
||||||
response = client.get("/api/v1/orders")
|
response = client.get("/api/v1/orders")
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|||||||
@@ -7,10 +7,18 @@ from datetime import UTC, datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
from app.db.models.order import Order
|
from app.db.models.order import Order
|
||||||
from app.schemas.orders import OrderUpdateIn
|
from app.schemas.orders import OrderUpdateIn
|
||||||
from app.services.orders import OrderEditError, _parse_crm_datetime, _to_kopecks, update_order
|
from app.services.orders import (
|
||||||
|
OrderEditError,
|
||||||
|
OrderTab,
|
||||||
|
_parse_crm_datetime,
|
||||||
|
_to_kopecks,
|
||||||
|
list_orders,
|
||||||
|
update_order,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestToKopecks:
|
class TestToKopecks:
|
||||||
@@ -148,3 +156,37 @@ class TestUpdateOrder:
|
|||||||
assert asyncio.run(update_order(_FakeSession(None), "1", payload)) is None # type: ignore[arg-type]
|
assert asyncio.run(update_order(_FakeSession(None), "1", payload)) is None # type: ignore[arg-type]
|
||||||
deleted = _FakeSession(_order(is_deleted=True))
|
deleted = _FakeSession(_order(is_deleted=True))
|
||||||
assert asyncio.run(update_order(deleted, "1", payload)) is None # type: ignore[arg-type]
|
assert asyncio.run(update_order(deleted, "1", payload)) is None # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
class _CapturingSession:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.statement: Any = None
|
||||||
|
|
||||||
|
async def scalars(self, statement: Any) -> list[Order]:
|
||||||
|
self.statement = statement
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _list_where(tab: OrderTab) -> str:
|
||||||
|
"""WHERE-часть запроса вкладки (SELECT перечисляет все колонки, в т.ч. receipt_created_at)."""
|
||||||
|
session = _CapturingSession()
|
||||||
|
asyncio.run(list_orders(session, tab=tab)) # type: ignore[arg-type]
|
||||||
|
sql = str(
|
||||||
|
session.statement.compile(
|
||||||
|
dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sql.split("WHERE", 1)[1]
|
||||||
|
|
||||||
|
|
||||||
|
class TestListOrders:
|
||||||
|
def test_refused_tab_filters_by_refusal_codes_only(self) -> None:
|
||||||
|
sql = _list_where(OrderTab.REFUSED)
|
||||||
|
assert "orders.np_status_code IN ('102', '103', '105', '108')" in sql
|
||||||
|
assert "receipt_created_at" not in sql
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tab", [OrderTab.NO_RECEIPT, OrderTab.HAS_RECEIPT])
|
||||||
|
def test_other_tabs_exclude_refusals(self, tab: OrderTab) -> None:
|
||||||
|
sql = _list_where(tab)
|
||||||
|
assert "NOT IN ('102', '103', '105', '108')" in sql
|
||||||
|
assert "receipt_created_at" in sql
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from app.db.models.order import Order
|
|||||||
from app.db.models.receipt import Receipt, ReceiptStatus
|
from app.db.models.receipt import Receipt, ReceiptStatus
|
||||||
from app.schemas.checkbox import EttnStatus
|
from app.schemas.checkbox import EttnStatus
|
||||||
from app.services import receipts as svc
|
from app.services import receipts as svc
|
||||||
from app.services.checkbox.client import CheckboxUnavailableError
|
from app.services.checkbox.client import CheckboxRateLimitedError, CheckboxUnavailableError
|
||||||
from app.services.checkbox.stub_client import StubCheckboxClient
|
from app.services.checkbox.stub_client import StubCheckboxClient
|
||||||
|
|
||||||
|
|
||||||
@@ -266,6 +266,24 @@ class TestCreateEttn:
|
|||||||
assert receipt.status == ReceiptStatus.CREATED
|
assert receipt.status == ReceiptStatus.CREATED
|
||||||
assert len(client.orders) == 1 # второго чека на ту же ТТН нет
|
assert len(client.orders) == 1 # второго чека на ту же ТТН нет
|
||||||
|
|
||||||
|
async def test_rate_limit_keeps_pending_without_unknown_outcome(self) -> None:
|
||||||
|
order, register = _order(receipt_created_at=datetime.now(UTC)), _register()
|
||||||
|
receipt = _pending(order, register)
|
||||||
|
client = StubCheckboxClient()
|
||||||
|
session = FakeSession(order, register, receipt)
|
||||||
|
|
||||||
|
async def rate_limited(creds: Any, body: dict[str, Any]) -> Any:
|
||||||
|
raise CheckboxRateLimitedError("To many requests", retry_after=1)
|
||||||
|
|
||||||
|
client.create_ettn = rate_limited # type: ignore[method-assign]
|
||||||
|
with pytest.raises(CheckboxRateLimitedError):
|
||||||
|
await svc.create_ettn_for_receipt(session, client, receipt.id)
|
||||||
|
|
||||||
|
assert receipt.status == ReceiptStatus.PENDING
|
||||||
|
assert receipt.error is None # повтор не пойдёт через find_ettn
|
||||||
|
assert order.receipt_created_at is not None # заказ не вернулся в очередь
|
||||||
|
assert session.commits == 1 # блокировка строки снята
|
||||||
|
|
||||||
|
|
||||||
class TestApplyEttn:
|
class TestApplyEttn:
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { apiFetch } from '@/api/client'
|
import { apiFetch } from '@/api/client'
|
||||||
import type { Order, OrderUpdate } from '@/features/orders/types'
|
import type { Order, OrderTab, OrderUpdate } from '@/features/orders/types'
|
||||||
|
|
||||||
export function getOrders(hasReceipt: boolean): Promise<Order[]> {
|
export function getOrders(tab: OrderTab): Promise<Order[]> {
|
||||||
return apiFetch<Order[]>(`/orders?has_receipt=${hasReceipt}`)
|
return apiFetch<Order[]>(`/orders?tab=${tab}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteOrder(orderId: string): Promise<void> {
|
export function deleteOrder(orderId: string): Promise<void> {
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
import type { ReceiptStatus } from '@/features/receipts/types'
|
import type { ReceiptStatus } from '@/features/receipts/types'
|
||||||
|
|
||||||
|
/** Вкладка дашборда (`OrderTab` в backend/app/services/orders.py). */
|
||||||
|
export type OrderTab = 'no_receipt' | 'has_receipt' | 'refused'
|
||||||
|
|
||||||
export interface OrderGood {
|
export interface OrderGood {
|
||||||
id: string
|
id: string
|
||||||
sku: string
|
sku: string
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
import { getOrders } from '@/api/orders'
|
import { getOrders } from '@/api/orders'
|
||||||
|
import type { OrderTab } from '@/features/orders/types'
|
||||||
|
|
||||||
/** Пока чек отправляется в Checkbox — опрашиваем часто, иначе статусы чеков обновляет worker раз в минуту. */
|
/** Пока чек отправляется в Checkbox — опрашиваем часто, иначе статусы чеков обновляет worker раз в минуту. */
|
||||||
const PENDING_POLL_MS = 3_000
|
const PENDING_POLL_MS = 3_000
|
||||||
const RECEIPTS_POLL_MS = 30_000
|
const RECEIPTS_POLL_MS = 30_000
|
||||||
|
|
||||||
export function useOrders(hasReceipt: boolean) {
|
export function useOrders(tab: OrderTab) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['orders', hasReceipt],
|
queryKey: ['orders', tab],
|
||||||
queryFn: () => getOrders(hasReceipt),
|
queryFn: () => getOrders(tab),
|
||||||
refetchInterval: (query) => {
|
refetchInterval: (query) => {
|
||||||
if (query.state.data?.some((order) => order.receipt_status === 'pending')) return PENDING_POLL_MS
|
if (query.state.data?.some((order) => order.receipt_status === 'pending')) return PENDING_POLL_MS
|
||||||
return hasReceipt ? RECEIPTS_POLL_MS : false
|
return tab === 'has_receipt' ? RECEIPTS_POLL_MS : false
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { cancelReceipt, createReceipts } from '@/api/receipts'
|
|||||||
import '@/pages/DashboardPage.css'
|
import '@/pages/DashboardPage.css'
|
||||||
import { useAuth } from '@/features/auth/useAuth'
|
import { useAuth } from '@/features/auth/useAuth'
|
||||||
import { OrderDetailModal } from '@/features/orders/OrderDetailModal'
|
import { OrderDetailModal } from '@/features/orders/OrderDetailModal'
|
||||||
import type { Order } from '@/features/orders/types'
|
import type { Order, OrderTab } from '@/features/orders/types'
|
||||||
import { useOrders } from '@/features/orders/useOrders'
|
import { useOrders } from '@/features/orders/useOrders'
|
||||||
import { defaultPrepayment, prepaymentMatches, toKopecks } from '@/features/receipts/money'
|
import { defaultPrepayment, prepaymentMatches, toKopecks } from '@/features/receipts/money'
|
||||||
import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types'
|
import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types'
|
||||||
@@ -19,11 +19,10 @@ const ROLE_LABEL: Record<string, string> = {
|
|||||||
viewer: 'Наблюдатель',
|
viewer: 'Наблюдатель',
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'no_receipt' | 'has_receipt'
|
const TABS: { key: OrderTab; label: string }[] = [
|
||||||
|
|
||||||
const TABS: { key: Tab; label: string }[] = [
|
|
||||||
{ key: 'no_receipt', label: 'Без чека' },
|
{ key: 'no_receipt', label: 'Без чека' },
|
||||||
{ key: 'has_receipt', label: 'Выписаны чеки' },
|
{ key: 'has_receipt', label: 'Выписаны чеки' },
|
||||||
|
{ key: 'refused', label: 'Отказы' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' {
|
function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' {
|
||||||
@@ -47,8 +46,8 @@ function paymentBadge(order: Order): { label: string; tone: 'delivered' | 'proce
|
|||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const [tab, setTab] = useState<Tab>('no_receipt')
|
const [tab, setTab] = useState<OrderTab>('no_receipt')
|
||||||
const { data: orders, isLoading, isError } = useOrders(tab === 'has_receipt')
|
const { data: orders, isLoading, isError } = useOrders(tab)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
@@ -103,7 +102,8 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
async function handleCancel(order: Order) {
|
async function handleCancel(order: Order) {
|
||||||
if (!order.receipt_id) return
|
if (!order.receipt_id) return
|
||||||
if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? Заказ вернётся в очередь.`)) return
|
const outcome = tab === 'refused' ? 'Заказ останется в отказах.' : 'Заказ вернётся в очередь.'
|
||||||
|
if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? ${outcome}`)) return
|
||||||
setCancellingId(order.id)
|
setCancellingId(order.id)
|
||||||
try {
|
try {
|
||||||
await cancelReceipt(order.receipt_id)
|
await cancelReceipt(order.receipt_id)
|
||||||
@@ -336,7 +336,7 @@ export function DashboardPage() {
|
|||||||
Чек
|
Чек
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{order.receipt_status && (tab === 'has_receipt' || !CANCELLABLE.has(order.receipt_status)) && (
|
{order.receipt_status && (tab !== 'no_receipt' || !CANCELLABLE.has(order.receipt_status)) && (
|
||||||
<span
|
<span
|
||||||
className={`orders-status orders-status--${RECEIPT_STATUS[order.receipt_status].tone}`}
|
className={`orders-status orders-status--${RECEIPT_STATUS[order.receipt_status].tone}`}
|
||||||
title={order.receipt_error ?? undefined}
|
title={order.receipt_error ?? undefined}
|
||||||
@@ -344,7 +344,7 @@ export function DashboardPage() {
|
|||||||
{RECEIPT_STATUS[order.receipt_status].label}
|
{RECEIPT_STATUS[order.receipt_status].label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{tab === 'has_receipt' && canFiscalize && order.receipt_status && CANCELLABLE.has(order.receipt_status) && (
|
{tab !== 'no_receipt' && canFiscalize && order.receipt_status && CANCELLABLE.has(order.receipt_status) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="orders-delete-btn"
|
className="orders-delete-btn"
|
||||||
@@ -354,7 +354,7 @@ export function DashboardPage() {
|
|||||||
Отменить
|
Отменить
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canDelete && tab === 'no_receipt' && (
|
{canDelete && (tab === 'no_receipt' || (tab === 'refused' && !order.has_receipt)) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="orders-delete-btn"
|
className="orders-delete-btn"
|
||||||
|
|||||||
Reference in New Issue
Block a user