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
+1 -1
View File
@@ -118,7 +118,7 @@ Not yet enforced by types anywhere in the current code, but is a hard project co
## Project status (see the plan for the full roadmap)
Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) are done. Checkbox ETTN receipts are implemented per `.plans/checkbox-ettn-receipts.md` but not yet verified on a real cash register.
Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) are done. The dashboard has three tabs (`GET /orders?tab=no_receipt|has_receipt|refused`, `services/orders.OrderTab`); an order whose NP status code is a refusal (`NP_REFUSAL_STATUS_CODES`) shows only under «Отказы», whether or not it has a receipt. Checkbox ETTN receipts are implemented per `.plans/checkbox-ettn-receipts.md` but not yet verified on a real cash register.
### Checkbox ETTN receipts
+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
+3 -3
View File
@@ -1,8 +1,8 @@
import { apiFetch } from '@/api/client'
import type { Order, OrderUpdate } from '@/features/orders/types'
import type { Order, OrderTab, OrderUpdate } from '@/features/orders/types'
export function getOrders(hasReceipt: boolean): Promise<Order[]> {
return apiFetch<Order[]>(`/orders?has_receipt=${hasReceipt}`)
export function getOrders(tab: OrderTab): Promise<Order[]> {
return apiFetch<Order[]>(`/orders?tab=${tab}`)
}
export function deleteOrder(orderId: string): Promise<void> {
+3
View File
@@ -5,6 +5,9 @@
import type { ReceiptStatus } from '@/features/receipts/types'
/** Вкладка дашборда (`OrderTab` в backend/app/services/orders.py). */
export type OrderTab = 'no_receipt' | 'has_receipt' | 'refused'
export interface OrderGood {
id: string
sku: string
+5 -4
View File
@@ -1,18 +1,19 @@
import { useQuery } from '@tanstack/react-query'
import { getOrders } from '@/api/orders'
import type { OrderTab } from '@/features/orders/types'
/** Пока чек отправляется в Checkbox — опрашиваем часто, иначе статусы чеков обновляет worker раз в минуту. */
const PENDING_POLL_MS = 3_000
const RECEIPTS_POLL_MS = 30_000
export function useOrders(hasReceipt: boolean) {
export function useOrders(tab: OrderTab) {
return useQuery({
queryKey: ['orders', hasReceipt],
queryFn: () => getOrders(hasReceipt),
queryKey: ['orders', tab],
queryFn: () => getOrders(tab),
refetchInterval: (query) => {
if (query.state.data?.some((order) => order.receipt_status === 'pending')) return PENDING_POLL_MS
return hasReceipt ? RECEIPTS_POLL_MS : false
return tab === 'has_receipt' ? RECEIPTS_POLL_MS : false
},
})
}
+10 -10
View File
@@ -7,7 +7,7 @@ import { cancelReceipt, createReceipts } from '@/api/receipts'
import '@/pages/DashboardPage.css'
import { useAuth } from '@/features/auth/useAuth'
import { OrderDetailModal } from '@/features/orders/OrderDetailModal'
import type { Order } from '@/features/orders/types'
import type { Order, OrderTab } from '@/features/orders/types'
import { useOrders } from '@/features/orders/useOrders'
import { defaultPrepayment, prepaymentMatches, toKopecks } from '@/features/receipts/money'
import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types'
@@ -19,11 +19,10 @@ const ROLE_LABEL: Record<string, string> = {
viewer: 'Наблюдатель',
}
type Tab = 'no_receipt' | 'has_receipt'
const TABS: { key: Tab; label: string }[] = [
const TABS: { key: OrderTab; label: string }[] = [
{ key: 'no_receipt', label: 'Без чека' },
{ key: 'has_receipt', label: 'Выписаны чеки' },
{ key: 'refused', label: 'Отказы' },
]
function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' {
@@ -47,8 +46,8 @@ function paymentBadge(order: Order): { label: string; tone: 'delivered' | 'proce
export function DashboardPage() {
const { user, logout } = useAuth()
const [tab, setTab] = useState<Tab>('no_receipt')
const { data: orders, isLoading, isError } = useOrders(tab === 'has_receipt')
const [tab, setTab] = useState<OrderTab>('no_receipt')
const { data: orders, isLoading, isError } = useOrders(tab)
const queryClient = useQueryClient()
const [selected, setSelected] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
@@ -103,7 +102,8 @@ export function DashboardPage() {
async function handleCancel(order: Order) {
if (!order.receipt_id) return
if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? Заказ вернётся в очередь.`)) return
const outcome = tab === 'refused' ? 'Заказ останется в отказах.' : 'Заказ вернётся в очередь.'
if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? ${outcome}`)) return
setCancellingId(order.id)
try {
await cancelReceipt(order.receipt_id)
@@ -336,7 +336,7 @@ export function DashboardPage() {
Чек
</button>
)}
{order.receipt_status && (tab === 'has_receipt' || !CANCELLABLE.has(order.receipt_status)) && (
{order.receipt_status && (tab !== 'no_receipt' || !CANCELLABLE.has(order.receipt_status)) && (
<span
className={`orders-status orders-status--${RECEIPT_STATUS[order.receipt_status].tone}`}
title={order.receipt_error ?? undefined}
@@ -344,7 +344,7 @@ export function DashboardPage() {
{RECEIPT_STATUS[order.receipt_status].label}
</span>
)}
{tab === 'has_receipt' && canFiscalize && order.receipt_status && CANCELLABLE.has(order.receipt_status) && (
{tab !== 'no_receipt' && canFiscalize && order.receipt_status && CANCELLABLE.has(order.receipt_status) && (
<button
type="button"
className="orders-delete-btn"
@@ -354,7 +354,7 @@ export function DashboardPage() {
Отменить
</button>
)}
{canDelete && tab === 'no_receipt' && (
{canDelete && (tab === 'no_receipt' || (tab === 'refused' && !order.has_receipt)) && (
<button
type="button"
className="orders-delete-btn"