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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user