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
+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: