Add refusals tab to the order dashboard

Orders whose Nova Poshta status is a refusal (102/103/105/108) now show
only under «Отказы», whether or not they have a receipt. GET /orders takes
tab=no_receipt|has_receipt|refused instead of has_receipt. NP statuses are
also polled for orders that already have a receipt, until NP reports a
final status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 21:25:59 +03:00
co-authored by Claude Opus 5.5
parent 56ac0fc370
commit f88b5cccc0
9 changed files with 116 additions and 33 deletions
+3 -2
View File
@@ -10,18 +10,19 @@ from app.schemas.orders import OrderRowOut, OrderUpdateIn
from app.services import audit
from app.services import orders as orders_service
from app.services import receipts as receipts_service
from app.services.orders import OrderTab
router = APIRouter(prefix="/orders", tags=["orders"], dependencies=[Depends(require_any)])
@router.get("", response_model=list[OrderRowOut], response_model_by_alias=False)
async def list_orders(
session: SessionDep, crm: CrmClientDep, has_receipt: bool = False
session: SessionDep, crm: CrmClientDep, tab: OrderTab = OrderTab.NO_RECEIPT
) -> list[OrderRowOut]:
# by_alias=False: без этого FastAPI сериализует вложенные OrderGoodOut через
# CRM-алиасы (SKU/Name/...) вместо snake_case полей, которые ждёт фронтенд.
await orders_service.sync_orders_from_crm(session, crm)
orders = await orders_service.list_orders(session, has_receipt=has_receipt)
orders = await orders_service.list_orders(session, tab=tab)
receipts = await receipts_service.latest_receipts_by_order(
session, [order.id for order in orders]
)
+35 -7
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import enum
import uuid
from datetime import UTC, datetime
from decimal import ROUND_HALF_UP, Decimal
@@ -20,6 +21,14 @@ _CRM_STATUS = "APPROVED"
# NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py).
_NP_BATCH_SIZE = 100
# Коды NP "відмова одержувача" / "припинено зберігання" — клиент не забрал
# посылку, заказ уходит во вкладку «Отказы».
NP_REFUSAL_STATUS_CODES = ("102", "103", "105", "108")
# Коды NP "відправлення отримано" и отказы — после них статус ТТН больше не
# опрашивается.
_NP_FINAL_STATUS_CODES = ("9", "10", "11", *NP_REFUSAL_STATUS_CODES)
# Поля, которые кассир может править в карточке заказа (кроме goods/total).
_EDITABLE_FIELDS = (
"recipient_name",
@@ -30,6 +39,12 @@ _EDITABLE_FIELDS = (
)
class OrderTab(enum.StrEnum):
NO_RECEIPT = "no_receipt"
HAS_RECEIPT = "has_receipt"
REFUSED = "refused"
class OrderEditError(Exception):
"""Заказ нельзя сохранить — сообщение показывается кассиру."""
@@ -80,21 +95,31 @@ async def sync_orders_from_crm(session: AsyncSession, crm: CrmClient) -> None:
await session.commit()
async def list_orders(session: AsyncSession, *, has_receipt: bool) -> list[Order]:
receipt_filter = (
Order.receipt_created_at.is_not(None) if has_receipt else Order.receipt_created_at.is_(None)
)
async def list_orders(session: AsyncSession, *, tab: OrderTab) -> list[Order]:
"""Заказы вкладки. Отказ клиента важнее наличия чека: такой заказ показывается
только в «Отказах», даже если по нему уже создан ЕТТН-чек."""
refused = Order.np_status_code.in_(NP_REFUSAL_STATUS_CODES)
not_refused = Order.np_status_code.is_(None) | ~refused
tab_filter = {
OrderTab.REFUSED: refused,
OrderTab.NO_RECEIPT: Order.receipt_created_at.is_(None) & not_refused,
OrderTab.HAS_RECEIPT: Order.receipt_created_at.is_not(None) & not_refused,
}[tab]
result = await session.scalars(
select(Order)
.where(Order.is_deleted.is_(False))
.where(receipt_filter)
.where(tab_filter)
.order_by(Order.create_date_time.desc())
)
return list(result)
async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
"""Обновляет статус ТТН и сумму наложенного платежа для заказов без чека.
"""Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути.
Опрашиваются и заказы с уже созданным ЕТТН-чеком: именно после создания
чека посылка доезжает и оплачивается, и этот переход кассир должен видеть.
Опрос прекращается, когда NP вернул финальный статус "отримано" или отказ.
Вызывается ARQ worker'ом раз в минуту (см. `app/worker.py`), а не из
HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас
@@ -103,8 +128,11 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
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))
.where(
Order.np_status_code.is_(None)
| Order.np_status_code.not_in(_NP_FINAL_STATUS_CODES)
)
)
orders_by_waybill: dict[str, Order] = {order.waybill_number: order for order in orders}
if not orders_by_waybill:
+13 -5
View File
@@ -69,8 +69,8 @@ def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_sync(session: object, crm: object) -> None:
return None
async def fake_list(session: object, *, has_receipt: bool) -> list[Order]:
return [] if has_receipt else [_order()]
async def fake_list(session: object, *, tab: str) -> list[Order]:
return [_order()] if tab == "no_receipt" else []
async def fake_delete(session: object, order_id: str) -> Order | None:
return _order(order_id) if order_id == "1" else None
@@ -132,7 +132,7 @@ class TestListOrders:
) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(role)
response = client.get("/api/v1/orders", params={"has_receipt": "false"})
response = client.get("/api/v1/orders", params={"tab": "no_receipt"})
assert response.status_code == 200
body = response.json()
@@ -142,14 +142,22 @@ class TestListOrders:
assert body[0]["has_receipt"] is False
assert body[0]["goods"][0]["sku"] == "SKU-1"
def test_has_receipt_tab_is_empty_for_now(self, client: TestClient) -> None:
@pytest.mark.parametrize("tab", ["has_receipt", "refused"])
def test_other_tabs_are_passed_to_service(self, client: TestClient, tab: str) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
response = client.get("/api/v1/orders", params={"has_receipt": "true"})
response = client.get("/api/v1/orders", params={"tab": tab})
assert response.status_code == 200
assert response.json() == []
def test_unknown_tab_is_rejected(self, client: TestClient) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
response = client.get("/api/v1/orders", params={"tab": "bogus"})
assert response.status_code == 422
def test_requires_authentication(self, client: TestClient) -> None:
response = client.get("/api/v1/orders")
assert response.status_code == 401
+43 -1
View File
@@ -7,10 +7,18 @@ from datetime import UTC, datetime
from typing import Any
import pytest
from sqlalchemy.dialects import postgresql
from app.db.models.order import Order
from app.schemas.orders import OrderUpdateIn
from app.services.orders import OrderEditError, _parse_crm_datetime, _to_kopecks, update_order
from app.services.orders import (
OrderEditError,
OrderTab,
_parse_crm_datetime,
_to_kopecks,
list_orders,
update_order,
)
class TestToKopecks:
@@ -148,3 +156,37 @@ class TestUpdateOrder:
assert asyncio.run(update_order(_FakeSession(None), "1", payload)) is None # type: ignore[arg-type]
deleted = _FakeSession(_order(is_deleted=True))
assert asyncio.run(update_order(deleted, "1", payload)) is None # type: ignore[arg-type]
class _CapturingSession:
def __init__(self) -> None:
self.statement: Any = None
async def scalars(self, statement: Any) -> list[Order]:
self.statement = statement
return []
def _list_where(tab: OrderTab) -> str:
"""WHERE-часть запроса вкладки (SELECT перечисляет все колонки, в т.ч. receipt_created_at)."""
session = _CapturingSession()
asyncio.run(list_orders(session, tab=tab)) # type: ignore[arg-type]
sql = str(
session.statement.compile(
dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}
)
)
return sql.split("WHERE", 1)[1]
class TestListOrders:
def test_refused_tab_filters_by_refusal_codes_only(self) -> None:
sql = _list_where(OrderTab.REFUSED)
assert "orders.np_status_code IN ('102', '103', '105', '108')" in sql
assert "receipt_created_at" not in sql
@pytest.mark.parametrize("tab", [OrderTab.NO_RECEIPT, OrderTab.HAS_RECEIPT])
def test_other_tabs_exclude_refusals(self, tab: OrderTab) -> None:
sql = _list_where(tab)
assert "NOT IN ('102', '103', '105', '108')" in sql
assert "receipt_created_at" in sql