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,30 @@
"""Статус ТТН Nova Poshta на заказе
Revision ID: 0003
Revises: 0002
Create Date: 2026-09-23
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0003"
down_revision: str | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("orders", sa.Column("np_status", sa.String(length=255), nullable=True))
op.add_column("orders", sa.Column("np_status_code", sa.String(length=16), nullable=True))
op.add_column("orders", sa.Column("np_cod_amount_kopecks", sa.BigInteger(), nullable=True))
def downgrade() -> None:
op.drop_column("orders", "np_cod_amount_kopecks")
op.drop_column("orders", "np_status_code")
op.drop_column("orders", "np_status")
@@ -0,0 +1,26 @@
"""Статус оплаты ТТН Nova Poshta на заказе
Revision ID: 0004
Revises: 0003
Create Date: 2026-09-23
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0004"
down_revision: str | None = "0003"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("orders", sa.Column("np_payment_status", sa.String(length=32), nullable=True))
def downgrade() -> None:
op.drop_column("orders", "np_payment_status")
+3
View File
@@ -62,6 +62,9 @@ class Settings(BaseSettings):
crm_shop_key: str = ""
crm_sid: int = 1
# --- Nova Poshta ---
nova_poshta_api_key: str = ""
@property
def cors_origins(self) -> list[str]:
"""Список разрешённых origin'ов из строки через запятую."""
+7
View File
@@ -43,3 +43,10 @@ class Order(TimestampMixin, Base):
# Заполняется будущей интеграцией с Checkbox — сейчас всегда NULL.
receipt_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
# Статус ТТН Nova Poshta — опрашивается ARQ worker'ом раз в минуту для
# заказов без чека, у которых есть номер ТТН (см. app/worker.py).
np_status: Mapped[str | None] = mapped_column(String(255))
np_status_code: Mapped[str | None] = mapped_column(String(16))
np_cod_amount_kopecks: Mapped[int | None] = mapped_column(BigInteger)
np_payment_status: Mapped[str | None] = mapped_column(String(32))
+12
View File
@@ -78,6 +78,10 @@ class OrderRowOut(BaseModel):
total_amount: str
goods: list[OrderGoodOut]
has_receipt: bool
np_status: str | None
np_status_code: str | None
np_cod_amount: str | None
np_payment_status: str | None
@classmethod
def from_order(cls, order: Order) -> OrderRowOut:
@@ -92,4 +96,12 @@ class OrderRowOut(BaseModel):
total_amount=f"{order.total_amount_kopecks / 100:.2f}",
goods=[OrderGoodOut.model_validate(good) for good in order.goods],
has_receipt=order.receipt_created_at is not None,
np_status=order.np_status,
np_status_code=order.np_status_code,
np_cod_amount=(
f"{order.np_cod_amount_kopecks / 100:.2f}"
if order.np_cod_amount_kopecks is not None
else None
),
np_payment_status=order.np_payment_status,
)
+51
View File
@@ -0,0 +1,51 @@
"""Схема статуса ТТН Nova Poshta (`TrackingDocument.getStatusDocuments`).
Поля валидируются из "сырых" PascalCase-ключей ответа NP через `Field(alias=...)`,
как и `OrderOut` для CRM. Суммы остаются строками "как есть" от NP — в
integer-копейки переводится только на границе с персистентной моделью.
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, model_validator
class TrackingStatusOut(BaseModel):
model_config = ConfigDict(populate_by_name=True)
number: str = Field(alias="Number")
status: str = Field(alias="Status")
status_code: str = Field(alias="StatusCode")
payment_method: str | None = Field(default=None, alias="PaymentMethod")
scheduled_delivery_date: str | None = Field(default=None, alias="ScheduledDeliveryDate")
actual_delivery_date: str | None = Field(default=None, alias="ActualDeliveryDate")
# Чистая сумма к перечислению продавцу за товар — поле `AfterpaymentOnGoodsCost`
# ("Контроль оплати" в кабинете NP). В отличие от `AmountToPay`/
# `ExpressWaybillAmountToPay` (сколько получатель должен заплатить НП прямо
# сейчас — включает стоимость доставки и комиссию НП и обнуляется после
# оплаты), это поле — зафиксированная при создании ТТН сумма за товар без
# доставки и комиссии, и не меняется по ходу доставки. `AmountToPay`/
# `ExpressWaybillAmountToPay` — запасной вариант на случай, если NP для
# какого-то типа накладной не возвращает `AfterpaymentOnGoodsCost`.
cod_amount: str | None = Field(default=None)
# Статус оплаты аналогично раздвоен на `PaymentStatus`/`ExpressWaybillPaymentStatus`.
payment_status: str | None = Field(default=None)
@model_validator(mode="before")
@classmethod
def _fold_express_fields(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
data = dict(data)
afterpayment = data.get("AfterpaymentOnGoodsCost")
fallback = data.get("AmountToPay") or data.get("ExpressWaybillAmountToPay") or None
data.setdefault("cod_amount", str(afterpayment) if afterpayment else fallback)
data.setdefault(
"payment_status",
data.get("PaymentStatus") or data.get("ExpressWaybillPaymentStatus") or None,
)
return data
@@ -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
]
+39
View File
@@ -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:
+37
View File
@@ -0,0 +1,37 @@
"""ARQ worker: раз в минуту опрашивает Nova Poshta по заказам без чека.
Запускается отдельным процессом: `arq app.worker.WorkerSettings`.
"""
from __future__ import annotations
from typing import Any
from arq import cron
from arq.connections import RedisSettings
from app.core.config import settings
from app.core.logging import configure_logging, get_logger
from app.db.session import SessionFactory
from app.services.nova_poshta.np_client import NpTrackingClient
from app.services.orders import sync_np_statuses
log = get_logger(__name__)
async def startup(ctx: dict[str, Any]) -> None:
configure_logging()
ctx["np_client"] = NpTrackingClient(settings)
log.info("worker_starting", environment=settings.environment)
async def poll_np_statuses(ctx: dict[str, Any]) -> None:
async with SessionFactory() as session:
await sync_np_statuses(session, ctx["np_client"])
log.info("np_statuses_polled")
class WorkerSettings:
redis_settings = RedisSettings.from_dsn(settings.redis_url)
on_startup = startup
cron_jobs = [cron(poll_np_statuses, minute=set(range(60)), run_at_startup=True)]
+112
View File
@@ -0,0 +1,112 @@
"""Тесты NpTrackingClient. Сеть замокана через respx — реальных запросов не делает."""
from __future__ import annotations
import json
import pytest
import respx
from httpx import Response
from app.core.config import Settings
from app.services.nova_poshta.client import NovaPoshtaError
from app.services.nova_poshta.np_client import _API_URL, NpTrackingClient
STATUS_PAYLOAD = {
"Number": "20451540916703",
"Status": "Відправник самостійно вказав цю накладну, але ще не надав до відправки",
"StatusCode": "1",
"PaymentMethod": "Cash",
"ScheduledDeliveryDate": "22-09-2026 18:00:00",
"ActualDeliveryDate": "",
"AmountToPay": "",
"ExpressWaybillAmountToPay": "827.48",
"AfterpaymentOnGoodsCost": 699,
"PaymentStatus": "",
"ExpressWaybillPaymentStatus": "NeedPayment",
}
def _settings() -> Settings:
return Settings(
secret_key="test-secret-key",
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
nova_poshta_api_key="np-apikey-123",
) # type: ignore[arg-type]
class TestNpTrackingClientGetStatuses:
@respx.mock
async def test_sends_expected_request_body(self) -> None:
route = respx.post(_API_URL).mock(
return_value=Response(200, json={"success": True, "data": [], "errors": []})
)
client = NpTrackingClient(_settings())
await client.get_statuses(waybill_numbers=["20451540916703"])
sent = route.calls.last.request
body = json.loads(sent.content)
assert body["apiKey"] == "np-apikey-123"
assert body["modelName"] == "TrackingDocument"
assert body["calledMethod"] == "getStatusDocuments"
assert body["methodProperties"]["Documents"] == [
{"DocumentNumber": "20451540916703", "Phone": ""}
]
@respx.mock
async def test_parses_successful_response_and_prefers_afterpayment_on_goods_cost(
self,
) -> None:
respx.post(_API_URL).mock(
return_value=Response(
200, json={"success": True, "data": [STATUS_PAYLOAD], "errors": []}
)
)
client = NpTrackingClient(_settings())
statuses = await client.get_statuses(waybill_numbers=["20451540916703"])
assert len(statuses) == 1
status = statuses[0]
assert status.number == "20451540916703"
assert status.status_code == "1"
# "Контроль оплати" — сумма за товар без стоимости доставки и комиссии НП,
# не "сколько заплатить сейчас" (`ExpressWaybillAmountToPay` = 827.48).
assert status.cod_amount == "699"
assert status.payment_status == "NeedPayment"
@respx.mock
async def test_falls_back_to_amount_to_pay_when_afterpayment_missing(self) -> None:
payload = {**STATUS_PAYLOAD, "AfterpaymentOnGoodsCost": 0}
respx.post(_API_URL).mock(
return_value=Response(200, json={"success": True, "data": [payload], "errors": []})
)
client = NpTrackingClient(_settings())
statuses = await client.get_statuses(waybill_numbers=["20451540916703"])
assert statuses[0].cod_amount == "827.48"
@respx.mock
async def test_raises_nova_poshta_error_on_failure(self) -> None:
respx.post(_API_URL).mock(
return_value=Response(
200, json={"success": False, "data": [], "errors": ["Invalid apiKey"]}
)
)
client = NpTrackingClient(_settings())
with pytest.raises(NovaPoshtaError, match="Invalid apiKey"):
await client.get_statuses(waybill_numbers=["20451540916703"])
async def test_returns_empty_list_for_no_documents(self) -> None:
client = NpTrackingClient(_settings())
assert await client.get_statuses(waybill_numbers=[]) == []
async def test_rejects_too_many_documents(self) -> None:
client = NpTrackingClient(_settings())
with pytest.raises(NovaPoshtaError, match="Слишком много"):
await client.get_statuses(waybill_numbers=[str(i) for i in range(101)])