From d13e7ce2b3a8f96fe61d3ced65aa8f80ad405f58 Mon Sep 17 00:00:00 2001 From: Liutenko Oleksandr Date: Wed, 23 Sep 2026 22:33:58 +0300 Subject: [PATCH] Add Nova Poshta tracking: TTN status, COD amount, payment status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 4 + README.md | 4 +- .../alembic/versions/0003_order_np_status.py | 30 +++++ .../versions/0004_order_np_payment_status.py | 26 ++++ backend/app/core/config.py | 3 + backend/app/db/models/order.py | 7 ++ backend/app/schemas/orders.py | 12 ++ backend/app/schemas/tracking.py | 51 ++++++++ backend/app/services/nova_poshta/__init__.py | 4 + backend/app/services/nova_poshta/client.py | 18 +++ backend/app/services/nova_poshta/np_client.py | 49 ++++++++ .../app/services/nova_poshta/stub_client.py | 31 +++++ backend/app/services/orders.py | 39 ++++++ backend/app/worker.py | 37 ++++++ backend/tests/test_nova_poshta_client.py | 112 ++++++++++++++++++ docker-compose.yml | 8 +- .../src/features/orders/OrderDetailModal.tsx | 18 +++ frontend/src/features/orders/types.ts | 4 + frontend/src/pages/DashboardPage.css | 10 ++ frontend/src/pages/DashboardPage.tsx | 43 +++++-- 20 files changed, 496 insertions(+), 14 deletions(-) create mode 100644 backend/alembic/versions/0003_order_np_status.py create mode 100644 backend/alembic/versions/0004_order_np_payment_status.py create mode 100644 backend/app/schemas/tracking.py create mode 100644 backend/app/services/nova_poshta/__init__.py create mode 100644 backend/app/services/nova_poshta/client.py create mode 100644 backend/app/services/nova_poshta/np_client.py create mode 100644 backend/app/services/nova_poshta/stub_client.py create mode 100644 backend/app/worker.py create mode 100644 backend/tests/test_nova_poshta_client.py diff --git a/.env.example b/.env.example index 0cfdf8d..995deb3 100644 --- a/.env.example +++ b/.env.example @@ -56,3 +56,7 @@ CRM_API_KEY=change-me-crm-apikey CRM_SECRET_KEY=change-me-crm-secretkey CRM_SHOP_KEY=change-me-crm-shopkey CRM_SID=1 + +# --- Nova Poshta ---------------------------------------------------------- +# Ключ API кабинета Nova Poshta (my.novaposhta.ua → Настройки → API). +NOVA_POSHTA_API_KEY=change-me-novaposhta-apikey diff --git a/README.md b/README.md index 9077f3d..3f0ae27 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ |---|---|---| | 1 | Каркас: FastAPI, Postgres, Alembic, Docker, логи, health-check | ✅ готово | | 2 | Аутентификация, роли, журнал аудита | ✅ готово | -| 3 | Заказы + заглушка CRM | ⏳ | -| 4 | Нова Пошта: статусы и сумма послеоплаты | ⏳ | +| 3 | Заказы + заглушка CRM | ✅ готово | +| 4 | Нова Пошта: статусы и сумма послеоплаты | ✅ готово | | 5 | Checkbox: смены, чеки, PDF | ⏳ | | 6 | Очередь и редактор чека (фронтенд) | ⏳ | | 7 | Админка | ⏳ | diff --git a/backend/alembic/versions/0003_order_np_status.py b/backend/alembic/versions/0003_order_np_status.py new file mode 100644 index 0000000..56b91b3 --- /dev/null +++ b/backend/alembic/versions/0003_order_np_status.py @@ -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") diff --git a/backend/alembic/versions/0004_order_np_payment_status.py b/backend/alembic/versions/0004_order_np_payment_status.py new file mode 100644 index 0000000..76a9939 --- /dev/null +++ b/backend/alembic/versions/0004_order_np_payment_status.py @@ -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") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 258239f..ac5a489 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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'ов из строки через запятую.""" diff --git a/backend/app/db/models/order.py b/backend/app/db/models/order.py index 4cee3a6..2c07efe 100644 --- a/backend/app/db/models/order.py +++ b/backend/app/db/models/order.py @@ -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)) diff --git a/backend/app/schemas/orders.py b/backend/app/schemas/orders.py index 0f4d112..8c1d66c 100644 --- a/backend/app/schemas/orders.py +++ b/backend/app/schemas/orders.py @@ -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, ) diff --git a/backend/app/schemas/tracking.py b/backend/app/schemas/tracking.py new file mode 100644 index 0000000..ed8d06c --- /dev/null +++ b/backend/app/schemas/tracking.py @@ -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 diff --git a/backend/app/services/nova_poshta/__init__.py b/backend/app/services/nova_poshta/__init__.py new file mode 100644 index 0000000..0bc0bbf --- /dev/null +++ b/backend/app/services/nova_poshta/__init__.py @@ -0,0 +1,4 @@ +"""Интеграция с Nova Poshta (статусы ТТН, сумма наложенного платежа). + +См. `client.py` за Protocol и `np_client.py`/`stub_client.py` за реализациями. +""" diff --git a/backend/app/services/nova_poshta/client.py b/backend/app/services/nova_poshta/client.py new file mode 100644 index 0000000..7403e84 --- /dev/null +++ b/backend/app/services/nova_poshta/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]: ... diff --git a/backend/app/services/nova_poshta/np_client.py b/backend/app/services/nova_poshta/np_client.py new file mode 100644 index 0000000..3134df8 --- /dev/null +++ b/backend/app/services/nova_poshta/np_client.py @@ -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", [])] diff --git a/backend/app/services/nova_poshta/stub_client.py b/backend/app/services/nova_poshta/stub_client.py new file mode 100644 index 0000000..aeca0c6 --- /dev/null +++ b/backend/app/services/nova_poshta/stub_client.py @@ -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 + ] diff --git a/backend/app/services/orders.py b/backend/app/services/orders.py index 363ad3e..7f049b3 100644 --- a/backend/app/services/orders.py +++ b/backend/app/services/orders.py @@ -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: diff --git a/backend/app/worker.py b/backend/app/worker.py new file mode 100644 index 0000000..96c9bed --- /dev/null +++ b/backend/app/worker.py @@ -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)] diff --git a/backend/tests/test_nova_poshta_client.py b/backend/tests/test_nova_poshta_client.py new file mode 100644 index 0000000..622f188 --- /dev/null +++ b/backend/tests/test_nova_poshta_client.py @@ -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)]) diff --git a/docker-compose.yml b/docker-compose.yml index b17a994..ff3cdc6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,11 +75,9 @@ services: environment: POSTGRES_HOST: postgres REDIS_HOST: redis - # Подключается на этапе 4 (Нова Пошта) — сейчас заглушка, чтобы структура - # compose не менялась задним числом. - command: ["python", "-c", "print('worker placeholder: см. этап 4 плана'); import time; time.sleep(3600)"] - # Заглушка ничего не слушает на 8000, а HEALTHCHECK бэкенда унаследован - # из общего образа — без отключения контейнер вечно висел бы "unhealthy". + command: ["arq", "app.worker.WorkerSettings"] + # ARQ ничего не слушает на 8000, а HEALTHCHECK бэкенда унаследован из + # общего образа — без отключения контейнер вечно висел бы "unhealthy". healthcheck: disable: true depends_on: diff --git a/frontend/src/features/orders/OrderDetailModal.tsx b/frontend/src/features/orders/OrderDetailModal.tsx index fbd78a0..ddb6a7a 100644 --- a/frontend/src/features/orders/OrderDetailModal.tsx +++ b/frontend/src/features/orders/OrderDetailModal.tsx @@ -27,6 +27,24 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) { Номер ТТН {order.waybill_number || '—'} +
+ Статус ТТН + {order.np_status || 'Нет данных'} +
+
+ Наложенный платёж + {order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'} +
+
+ Оплачено + + {order.np_payment_status === 'Payed' + ? 'Да' + : order.np_payment_status === 'NeedPayment' + ? 'Нет' + : '—'} + +
Клиент {order.recipient_name || '—'} diff --git a/frontend/src/features/orders/types.ts b/frontend/src/features/orders/types.ts index 1334dc9..f012109 100644 --- a/frontend/src/features/orders/types.ts +++ b/frontend/src/features/orders/types.ts @@ -25,4 +25,8 @@ export interface Order { total_amount: string goods: OrderGood[] has_receipt: boolean + np_status: string | null + np_status_code: string | null + np_cod_amount: string | null + np_payment_status: string | null } diff --git a/frontend/src/pages/DashboardPage.css b/frontend/src/pages/DashboardPage.css index c17555d..a0da5c7 100644 --- a/frontend/src/pages/DashboardPage.css +++ b/frontend/src/pages/DashboardPage.css @@ -229,6 +229,11 @@ color: #166534; } +.orders-status--danger { + background: #fee2e2; + color: #991b1b; +} + @media (prefers-color-scheme: dark) { .orders-status--processing { background: rgba(217, 119, 6, 0.18); @@ -249,6 +254,11 @@ background: rgba(22, 163, 74, 0.18); color: #86efac; } + + .orders-status--danger { + background: rgba(220, 38, 38, 0.18); + color: #fca5a5; + } } .orders-actions-cell { diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index e57d907..08737b3 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -21,6 +21,20 @@ const TABS: { key: Tab; label: string }[] = [ { 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() { const { user, logout } = useAuth() const [tab, setTab] = useState('no_receipt') @@ -134,29 +148,33 @@ export function DashboardPage() { Номер ТТН Клиент Сумма + Наложка + Оплачено Предоплата - Статус + Статус ТТН Действия {isLoading && ( - + Загрузка… )} {isError && ( - + Не удалось загрузить заказы из CRM )} {!isLoading && !isError && - filtered.map((order) => ( + filtered.map((order) => { + const badge = paymentBadge(order) + return ( {order.waybill_number || '—'} {order.recipient_name || '—'} {order.total_amount} ₴ + {order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'} + + {badge ? ( + {badge.label} + ) : ( + '—' + )} + - Одобрен + + {order.np_status || 'Нет данных'} +