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:
@@ -56,3 +56,7 @@ CRM_API_KEY=change-me-crm-apikey
|
|||||||
CRM_SECRET_KEY=change-me-crm-secretkey
|
CRM_SECRET_KEY=change-me-crm-secretkey
|
||||||
CRM_SHOP_KEY=change-me-crm-shopkey
|
CRM_SHOP_KEY=change-me-crm-shopkey
|
||||||
CRM_SID=1
|
CRM_SID=1
|
||||||
|
|
||||||
|
# --- Nova Poshta ----------------------------------------------------------
|
||||||
|
# Ключ API кабинета Nova Poshta (my.novaposhta.ua → Настройки → API).
|
||||||
|
NOVA_POSHTA_API_KEY=change-me-novaposhta-apikey
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | Каркас: FastAPI, Postgres, Alembic, Docker, логи, health-check | ✅ готово |
|
| 1 | Каркас: FastAPI, Postgres, Alembic, Docker, логи, health-check | ✅ готово |
|
||||||
| 2 | Аутентификация, роли, журнал аудита | ✅ готово |
|
| 2 | Аутентификация, роли, журнал аудита | ✅ готово |
|
||||||
| 3 | Заказы + заглушка CRM | ⏳ |
|
| 3 | Заказы + заглушка CRM | ✅ готово |
|
||||||
| 4 | Нова Пошта: статусы и сумма послеоплаты | ⏳ |
|
| 4 | Нова Пошта: статусы и сумма послеоплаты | ✅ готово |
|
||||||
| 5 | Checkbox: смены, чеки, PDF | ⏳ |
|
| 5 | Checkbox: смены, чеки, PDF | ⏳ |
|
||||||
| 6 | Очередь и редактор чека (фронтенд) | ⏳ |
|
| 6 | Очередь и редактор чека (фронтенд) | ⏳ |
|
||||||
| 7 | Админка | ⏳ |
|
| 7 | Админка | ⏳ |
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -62,6 +62,9 @@ class Settings(BaseSettings):
|
|||||||
crm_shop_key: str = ""
|
crm_shop_key: str = ""
|
||||||
crm_sid: int = 1
|
crm_sid: int = 1
|
||||||
|
|
||||||
|
# --- Nova Poshta ---
|
||||||
|
nova_poshta_api_key: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cors_origins(self) -> list[str]:
|
def cors_origins(self) -> list[str]:
|
||||||
"""Список разрешённых origin'ов из строки через запятую."""
|
"""Список разрешённых origin'ов из строки через запятую."""
|
||||||
|
|||||||
@@ -43,3 +43,10 @@ class Order(TimestampMixin, Base):
|
|||||||
|
|
||||||
# Заполняется будущей интеграцией с Checkbox — сейчас всегда NULL.
|
# Заполняется будущей интеграцией с Checkbox — сейчас всегда NULL.
|
||||||
receipt_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
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))
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ class OrderRowOut(BaseModel):
|
|||||||
total_amount: str
|
total_amount: str
|
||||||
goods: list[OrderGoodOut]
|
goods: list[OrderGoodOut]
|
||||||
has_receipt: bool
|
has_receipt: bool
|
||||||
|
np_status: str | None
|
||||||
|
np_status_code: str | None
|
||||||
|
np_cod_amount: str | None
|
||||||
|
np_payment_status: str | None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_order(cls, order: Order) -> OrderRowOut:
|
def from_order(cls, order: Order) -> OrderRowOut:
|
||||||
@@ -92,4 +96,12 @@ class OrderRowOut(BaseModel):
|
|||||||
total_amount=f"{order.total_amount_kopecks / 100:.2f}",
|
total_amount=f"{order.total_amount_kopecks / 100:.2f}",
|
||||||
goods=[OrderGoodOut.model_validate(good) for good in order.goods],
|
goods=[OrderGoodOut.model_validate(good) for good in order.goods],
|
||||||
has_receipt=order.receipt_created_at is not None,
|
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
|
||||||
@@ -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.db.models.order import Order
|
||||||
from app.services.crm.client import CrmClient
|
from app.services.crm.client import CrmClient
|
||||||
|
from app.services.nova_poshta.client import NovaPoshtaClient
|
||||||
|
|
||||||
_CRM_STATUS = "APPROVED"
|
_CRM_STATUS = "APPROVED"
|
||||||
|
|
||||||
|
# NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py).
|
||||||
|
_NP_BATCH_SIZE = 100
|
||||||
|
|
||||||
|
|
||||||
def _to_kopecks(amount: str) -> int:
|
def _to_kopecks(amount: str) -> int:
|
||||||
return int((Decimal(amount) * 100).to_integral_value())
|
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)
|
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:
|
async def delete_order(session: AsyncSession, order_id: str) -> Order | None:
|
||||||
order = await session.get(Order, order_id)
|
order = await session.get(Order, order_id)
|
||||||
if order is None or order.is_deleted:
|
if order is None or order.is_deleted:
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -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)])
|
||||||
+3
-5
@@ -75,11 +75,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_HOST: postgres
|
POSTGRES_HOST: postgres
|
||||||
REDIS_HOST: redis
|
REDIS_HOST: redis
|
||||||
# Подключается на этапе 4 (Нова Пошта) — сейчас заглушка, чтобы структура
|
command: ["arq", "app.worker.WorkerSettings"]
|
||||||
# compose не менялась задним числом.
|
# ARQ ничего не слушает на 8000, а HEALTHCHECK бэкенда унаследован из
|
||||||
command: ["python", "-c", "print('worker placeholder: см. этап 4 плана'); import time; time.sleep(3600)"]
|
# общего образа — без отключения контейнер вечно висел бы "unhealthy".
|
||||||
# Заглушка ничего не слушает на 8000, а HEALTHCHECK бэкенда унаследован
|
|
||||||
# из общего образа — без отключения контейнер вечно висел бы "unhealthy".
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
disable: true
|
disable: true
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -27,6 +27,24 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
|
|||||||
<span className="order-modal-label">Номер ТТН</span>
|
<span className="order-modal-label">Номер ТТН</span>
|
||||||
<span>{order.waybill_number || '—'}</span>
|
<span>{order.waybill_number || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="order-modal-label">Статус ТТН</span>
|
||||||
|
<span>{order.np_status || 'Нет данных'}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="order-modal-label">Наложенный платёж</span>
|
||||||
|
<span>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="order-modal-label">Оплачено</span>
|
||||||
|
<span>
|
||||||
|
{order.np_payment_status === 'Payed'
|
||||||
|
? 'Да'
|
||||||
|
: order.np_payment_status === 'NeedPayment'
|
||||||
|
? 'Нет'
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="order-modal-label">Клиент</span>
|
<span className="order-modal-label">Клиент</span>
|
||||||
<span>{order.recipient_name || '—'}</span>
|
<span>{order.recipient_name || '—'}</span>
|
||||||
|
|||||||
@@ -25,4 +25,8 @@ export interface Order {
|
|||||||
total_amount: string
|
total_amount: string
|
||||||
goods: OrderGood[]
|
goods: OrderGood[]
|
||||||
has_receipt: boolean
|
has_receipt: boolean
|
||||||
|
np_status: string | null
|
||||||
|
np_status_code: string | null
|
||||||
|
np_cod_amount: string | null
|
||||||
|
np_payment_status: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,11 @@
|
|||||||
color: #166534;
|
color: #166534;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.orders-status--danger {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.orders-status--processing {
|
.orders-status--processing {
|
||||||
background: rgba(217, 119, 6, 0.18);
|
background: rgba(217, 119, 6, 0.18);
|
||||||
@@ -249,6 +254,11 @@
|
|||||||
background: rgba(22, 163, 74, 0.18);
|
background: rgba(22, 163, 74, 0.18);
|
||||||
color: #86efac;
|
color: #86efac;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.orders-status--danger {
|
||||||
|
background: rgba(220, 38, 38, 0.18);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.orders-actions-cell {
|
.orders-actions-cell {
|
||||||
|
|||||||
@@ -21,6 +21,20 @@ const TABS: { key: Tab; label: string }[] = [
|
|||||||
{ key: 'has_receipt', label: 'Выписаны чеки' },
|
{ key: 'has_receipt', label: 'Выписаны чеки' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' {
|
||||||
|
const text = (order.np_status ?? '').toLowerCase()
|
||||||
|
if (text.includes('видано') || text.includes('отримано')) return 'delivered'
|
||||||
|
if (text.includes('відмова') || text.includes('не забра') || text.includes('поверн')) return 'danger'
|
||||||
|
if (text) return 'processing'
|
||||||
|
return 'new'
|
||||||
|
}
|
||||||
|
|
||||||
|
function paymentBadge(order: Order): { label: string; tone: 'delivered' | 'processing' } | null {
|
||||||
|
if (order.np_payment_status === 'Payed') return { label: 'Оплачено', tone: 'delivered' }
|
||||||
|
if (order.np_payment_status === 'NeedPayment') return { label: 'Не оплачено', tone: 'processing' }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const [tab, setTab] = useState<Tab>('no_receipt')
|
const [tab, setTab] = useState<Tab>('no_receipt')
|
||||||
@@ -134,29 +148,33 @@ export function DashboardPage() {
|
|||||||
<th>Номер ТТН</th>
|
<th>Номер ТТН</th>
|
||||||
<th>Клиент</th>
|
<th>Клиент</th>
|
||||||
<th>Сумма</th>
|
<th>Сумма</th>
|
||||||
|
<th>Наложка</th>
|
||||||
|
<th>Оплачено</th>
|
||||||
<th>Предоплата</th>
|
<th>Предоплата</th>
|
||||||
<th>Статус</th>
|
<th>Статус ТТН</th>
|
||||||
<th>Действия</th>
|
<th>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="orders-empty">
|
<td colSpan={10} className="orders-empty">
|
||||||
Загрузка…
|
Загрузка…
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{isError && (
|
{isError && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="orders-empty">
|
<td colSpan={10} className="orders-empty">
|
||||||
Не удалось загрузить заказы из CRM
|
Не удалось загрузить заказы из CRM
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{!isLoading &&
|
{!isLoading &&
|
||||||
!isError &&
|
!isError &&
|
||||||
filtered.map((order) => (
|
filtered.map((order) => {
|
||||||
|
const badge = paymentBadge(order)
|
||||||
|
return (
|
||||||
<tr key={order.id}>
|
<tr key={order.id}>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
@@ -170,11 +188,21 @@ export function DashboardPage() {
|
|||||||
<td>{order.waybill_number || '—'}</td>
|
<td>{order.waybill_number || '—'}</td>
|
||||||
<td>{order.recipient_name || '—'}</td>
|
<td>{order.recipient_name || '—'}</td>
|
||||||
<td>{order.total_amount} ₴</td>
|
<td>{order.total_amount} ₴</td>
|
||||||
|
<td>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</td>
|
||||||
|
<td>
|
||||||
|
{badge ? (
|
||||||
|
<span className={`orders-status orders-status--${badge.tone}`}>{badge.label}</span>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="text" className="orders-prepayment-input" placeholder="0%" />
|
<input type="text" className="orders-prepayment-input" placeholder="0%" />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="orders-status orders-status--approved">Одобрен</span>
|
<span className={`orders-status orders-status--${npStatusTone(order)}`}>
|
||||||
|
{order.np_status || 'Нет данных'}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="orders-actions-cell">
|
<td className="orders-actions-cell">
|
||||||
<button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}>
|
<button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}>
|
||||||
@@ -195,10 +223,11 @@ export function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
{!isLoading && !isError && filtered.length === 0 && (
|
{!isLoading && !isError && filtered.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="orders-empty">
|
<td colSpan={10} className="orders-empty">
|
||||||
Ничего не найдено
|
Ничего не найдено
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user