Add Nova Poshta tracking: TTN status, COD amount, payment status

Adds NpTrackingClient (Protocol + real/stub impls) and an ARQ worker that
polls Nova Poshta every minute for orders without a receipt, writing
status, net COD amount (Контроль оплати), and payment status onto the
order. Surfaced in the orders table and detail modal. Marks plan stages
3-4 done in README.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 22:33:58 +03:00
co-authored by Claude Sonnet 5
parent f4072be451
commit d13e7ce2b3
20 changed files with 496 additions and 14 deletions
@@ -0,0 +1,4 @@
"""Интеграция с Nova Poshta (статусы ТТН, сумма наложенного платежа).
См. `client.py` за Protocol и `np_client.py`/`stub_client.py` за реализациями.
"""
@@ -0,0 +1,18 @@
"""Protocol клиента Nova Poshta — позволяет подменять реализацию в тестах.
См. `StubNovaPoshtaClient`.
"""
from __future__ import annotations
from typing import Protocol
from app.schemas.tracking import TrackingStatusOut
class NovaPoshtaError(Exception):
"""NP API ответил `success: false` (см. поле `errors` в ответе)."""
class NovaPoshtaClient(Protocol):
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]: ...
@@ -0,0 +1,49 @@
"""Реальный клиент Nova Poshta (`TrackingDocument.getStatusDocuments`)."""
from __future__ import annotations
import httpx
from app.core.config import Settings
from app.schemas.tracking import TrackingStatusOut
from app.services.nova_poshta.client import NovaPoshtaError
_API_URL = "https://api.novaposhta.ua/v2.0/json/"
# NP отклоняет запросы с более чем 100 накладными за раз.
_MAX_DOCUMENTS_PER_REQUEST = 100
class NpTrackingClient:
def __init__(self, settings: Settings) -> None:
self._api_key = settings.nova_poshta_api_key
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]:
if not waybill_numbers:
return []
if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST:
raise NovaPoshtaError(
f"Слишком много ТТН за один запрос: {len(waybill_numbers)} "
f"(максимум {_MAX_DOCUMENTS_PER_REQUEST})"
)
body = {
"apiKey": self._api_key,
"modelName": "TrackingDocument",
"calledMethod": "getStatusDocuments",
"methodProperties": {
"Documents": [{"DocumentNumber": number, "Phone": ""} for number in waybill_numbers]
},
}
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(_API_URL, json=body)
response.raise_for_status()
data = response.json()
if not data.get("success"):
errors = data.get("errors") or []
message = "; ".join(str(error) for error in errors)
raise NovaPoshtaError(f"NP вернул ошибку: {message or 'неизвестная ошибка'}")
return [TrackingStatusOut.model_validate(item) for item in data.get("data", [])]
@@ -0,0 +1,31 @@
"""Фикстурный клиент Nova Poshta для тестов — не ходит в сеть."""
from __future__ import annotations
from app.schemas.tracking import TrackingStatusOut
_FIXTURE_STATUSES: dict[str, dict] = {
"20450123456789": {
"Number": "20450123456789",
"Status": "Відправлення отримано",
"StatusCode": "9",
"PaymentMethod": "Cash",
"ScheduledDeliveryDate": "22-09-2026 18:00:00",
"ActualDeliveryDate": "22-09-2026 15:30:00",
"AmountToPay": "1200.00",
"AfterpaymentOnGoodsCost": 1200,
"PaymentStatus": "Paid",
}
}
class StubNovaPoshtaClient:
def __init__(self, statuses: dict[str, dict] | None = None) -> None:
self._statuses = statuses if statuses is not None else _FIXTURE_STATUSES
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]:
return [
TrackingStatusOut.model_validate(self._statuses[number])
for number in waybill_numbers
if number in self._statuses
]