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:
@@ -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
|
||||
]
|
||||
@@ -10,9 +10,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models.order import Order
|
||||
from app.services.crm.client import CrmClient
|
||||
from app.services.nova_poshta.client import NovaPoshtaClient
|
||||
|
||||
_CRM_STATUS = "APPROVED"
|
||||
|
||||
# NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py).
|
||||
_NP_BATCH_SIZE = 100
|
||||
|
||||
|
||||
def _to_kopecks(amount: str) -> int:
|
||||
return int((Decimal(amount) * 100).to_integral_value())
|
||||
@@ -72,6 +76,41 @@ async def list_orders(session: AsyncSession, *, has_receipt: bool) -> list[Order
|
||||
return list(result)
|
||||
|
||||
|
||||
async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
|
||||
"""Обновляет статус ТТН и сумму наложенного платежа для заказов без чека.
|
||||
|
||||
Вызывается ARQ worker'ом раз в минуту (см. `app/worker.py`), а не из
|
||||
HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас
|
||||
дашборд.
|
||||
"""
|
||||
orders = await session.scalars(
|
||||
select(Order)
|
||||
.where(Order.is_deleted.is_(False))
|
||||
.where(Order.receipt_created_at.is_(None))
|
||||
.where(Order.waybill_number.is_not(None))
|
||||
)
|
||||
orders_by_waybill: dict[str, Order] = {order.waybill_number: order for order in orders}
|
||||
if not orders_by_waybill:
|
||||
return
|
||||
|
||||
waybill_numbers = list(orders_by_waybill)
|
||||
for i in range(0, len(waybill_numbers), _NP_BATCH_SIZE):
|
||||
batch = waybill_numbers[i : i + _NP_BATCH_SIZE]
|
||||
statuses = await np.get_statuses(waybill_numbers=batch)
|
||||
for tracking_status in statuses:
|
||||
order = orders_by_waybill.get(tracking_status.number)
|
||||
if order is None:
|
||||
continue
|
||||
order.np_status = tracking_status.status
|
||||
order.np_status_code = tracking_status.status_code
|
||||
order.np_cod_amount_kopecks = (
|
||||
_to_kopecks(tracking_status.cod_amount) if tracking_status.cod_amount else None
|
||||
)
|
||||
order.np_payment_status = tracking_status.payment_status
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def delete_order(session: AsyncSession, order_id: str) -> Order | None:
|
||||
order = await session.get(Order, order_id)
|
||||
if order is None or order.is_deleted:
|
||||
|
||||
Reference in New Issue
Block a user